Skip to content

Commit 01093a7

Browse files
authored
feat!: Modify operators to use 3-valued logic instead of classical boolean logic (BREAKING) (#765)
* Modify operators to use 3-valued logic instead of classical boolean logic * Changeset * Changeset
1 parent c5de261 commit 01093a7

File tree

8 files changed

+495
-27
lines changed

8 files changed

+495
-27
lines changed

.changeset/whole-pants-strive.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@tanstack/db": major
3+
---
4+
5+
Implement 3-valued logic (true/false/unknown) for all comparison and logical operators.
6+
Queries with null/undefined values now behave consistently with SQL databases, where UNKNOWN results exclude rows from WHERE clauses.
7+
8+
**Breaking Change**: This changes the behavior of `WHERE` and `HAVING` clauses when dealing with `null` and `undefined` values.
9+
10+
**Example 1: Equality checks with null**
11+
12+
Previously, this query would return all persons with `age = null`:
13+
14+
```ts
15+
q.from(...).where(({ person }) => eq(person.age, null))
16+
```
17+
18+
With 3-valued logic, `eq(anything, null)` evaluates to `null` (UNKNOWN) and is filtered out. Use `isNull()` instead:
19+
20+
```ts
21+
q.from(...).where(({ person }) => isNull(person.age))
22+
```
23+
24+
**Example 2: Comparisons with null values**
25+
26+
Previously, this query would return persons with `age < 18` OR `age = null`:
27+
28+
```ts
29+
q.from(...).where(({ person }) => lt(person.age, 18))
30+
```
31+
32+
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()`:
33+
34+
```ts
35+
q.from(...).where(({ person }) =>
36+
or(lt(person.age, 18), isNull(person.age))
37+
)
38+
```

packages/db/src/collection/change-events.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import {
22
createSingleRowRefProxy,
33
toExpression,
44
} from "../query/builder/ref-proxy"
5-
import { compileSingleRowExpression } from "../query/compiler/evaluators.js"
5+
import {
6+
compileSingleRowExpression,
7+
toBooleanPredicate,
8+
} from "../query/compiler/evaluators.js"
69
import {
710
findIndexForField,
811
optimizeExpressionWithIndexes,
@@ -199,7 +202,7 @@ export function createFilterFunction<T extends object>(
199202
const evaluator = compileSingleRowExpression(expression)
200203
const result = evaluator(item as Record<string, unknown>)
201204
// WHERE clauses should always evaluate to boolean predicates (Kevin's feedback)
202-
return result
205+
return toBooleanPredicate(result)
203206
} catch {
204207
// If RefProxy approach fails (e.g., arithmetic operations), fall back to direct evaluation
205208
try {
@@ -211,7 +214,7 @@ export function createFilterFunction<T extends object>(
211214
}) as SingleRowRefProxy<T>
212215

213216
const result = whereCallback(simpleProxy)
214-
return result
217+
return toBooleanPredicate(result)
215218
} catch {
216219
// If both approaches fail, exclude the item
217220
return false
@@ -232,7 +235,7 @@ export function createFilterFunctionFromExpression<T extends object>(
232235
try {
233236
const evaluator = compileSingleRowExpression(expression)
234237
const result = evaluator(item as Record<string, unknown>)
235-
return Boolean(result)
238+
return toBooleanPredicate(result)
236239
} catch {
237240
// If evaluation fails, exclude the item
238241
return false

packages/db/src/query/compiler/evaluators.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ import { normalizeValue } from "../../utils/comparison.js"
77
import type { BasicExpression, Func, PropRef } from "../ir.js"
88
import type { NamespacedRow } from "../../types.js"
99

10+
/**
11+
* Helper function to check if a value is null or undefined (represents UNKNOWN in 3-valued logic)
12+
*/
13+
function isUnknown(value: any): boolean {
14+
return value === null || value === undefined
15+
}
16+
17+
/**
18+
* Converts a 3-valued logic result to a boolean for use in WHERE/HAVING filters.
19+
* In SQL, UNKNOWN (null) values in WHERE clauses exclude rows, matching false behavior.
20+
*
21+
* @param result - The 3-valued logic result: true, false, or null (UNKNOWN)
22+
* @returns true only if result is explicitly true, false otherwise
23+
*
24+
* Truth table:
25+
* - true → true (include row)
26+
* - false → false (exclude row)
27+
* - null (UNKNOWN) → false (exclude row, matching SQL behavior)
28+
*/
29+
export function toBooleanPredicate(result: boolean | null): boolean {
30+
return result === true
31+
}
32+
1033
/**
1134
* Compiled expression evaluator function type
1235
*/
@@ -145,6 +168,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
145168
return (data) => {
146169
const a = normalizeValue(argA(data))
147170
const b = normalizeValue(argB(data))
171+
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
172+
if (isUnknown(a) || isUnknown(b)) {
173+
return null
174+
}
148175
return a === b
149176
}
150177
}
@@ -154,6 +181,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
154181
return (data) => {
155182
const a = argA(data)
156183
const b = argB(data)
184+
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
185+
if (isUnknown(a) || isUnknown(b)) {
186+
return null
187+
}
157188
return a > b
158189
}
159190
}
@@ -163,6 +194,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
163194
return (data) => {
164195
const a = argA(data)
165196
const b = argB(data)
197+
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
198+
if (isUnknown(a) || isUnknown(b)) {
199+
return null
200+
}
166201
return a >= b
167202
}
168203
}
@@ -172,6 +207,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
172207
return (data) => {
173208
const a = argA(data)
174209
const b = argB(data)
210+
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
211+
if (isUnknown(a) || isUnknown(b)) {
212+
return null
213+
}
175214
return a < b
176215
}
177216
}
@@ -181,32 +220,78 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
181220
return (data) => {
182221
const a = argA(data)
183222
const b = argB(data)
223+
// In 3-valued logic, any comparison with null/undefined returns UNKNOWN
224+
if (isUnknown(a) || isUnknown(b)) {
225+
return null
226+
}
184227
return a <= b
185228
}
186229
}
187230

188231
// Boolean operators
189232
case `and`:
190233
return (data) => {
234+
// 3-valued logic for AND:
235+
// - false AND anything = false (short-circuit)
236+
// - null AND false = false
237+
// - null AND anything (except false) = null
238+
// - anything (except false) AND null = null
239+
// - true AND true = true
240+
let hasUnknown = false
191241
for (const compiledArg of compiledArgs) {
192-
if (!compiledArg(data)) {
242+
const result = compiledArg(data)
243+
if (result === false) {
193244
return false
194245
}
246+
if (isUnknown(result)) {
247+
hasUnknown = true
248+
}
249+
}
250+
// If we got here, no operand was false
251+
// If any operand was null, return null (UNKNOWN)
252+
if (hasUnknown) {
253+
return null
195254
}
255+
196256
return true
197257
}
198258
case `or`:
199259
return (data) => {
260+
// 3-valued logic for OR:
261+
// - true OR anything = true (short-circuit)
262+
// - null OR anything (except true) = null
263+
// - false OR false = false
264+
let hasUnknown = false
200265
for (const compiledArg of compiledArgs) {
201-
if (compiledArg(data)) {
266+
const result = compiledArg(data)
267+
if (result === true) {
202268
return true
203269
}
270+
if (isUnknown(result)) {
271+
hasUnknown = true
272+
}
273+
}
274+
// If we got here, no operand was true
275+
// If any operand was null, return null (UNKNOWN)
276+
if (hasUnknown) {
277+
return null
204278
}
279+
205280
return false
206281
}
207282
case `not`: {
208283
const arg = compiledArgs[0]!
209-
return (data) => !arg(data)
284+
return (data) => {
285+
// 3-valued logic for NOT:
286+
// - NOT null = null
287+
// - NOT true = false
288+
// - NOT false = true
289+
const result = arg(data)
290+
if (isUnknown(result)) {
291+
return null
292+
}
293+
return !result
294+
}
210295
}
211296

212297
// Array operators
@@ -216,6 +301,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
216301
return (data) => {
217302
const value = valueEvaluator(data)
218303
const array = arrayEvaluator(data)
304+
// In 3-valued logic, if the value is null/undefined, return UNKNOWN
305+
if (isUnknown(value)) {
306+
return null
307+
}
219308
if (!Array.isArray(array)) {
220309
return false
221310
}
@@ -230,6 +319,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
230319
return (data) => {
231320
const value = valueEvaluator(data)
232321
const pattern = patternEvaluator(data)
322+
// In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN
323+
if (isUnknown(value) || isUnknown(pattern)) {
324+
return null
325+
}
233326
return evaluateLike(value, pattern, false)
234327
}
235328
}
@@ -239,6 +332,10 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
239332
return (data) => {
240333
const value = valueEvaluator(data)
241334
const pattern = patternEvaluator(data)
335+
// In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN
336+
if (isUnknown(value) || isUnknown(pattern)) {
337+
return null
338+
}
242339
return evaluateLike(value, pattern, true)
243340
}
244341
}

packages/db/src/query/compiler/group-by.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
UnknownHavingExpressionTypeError,
77
UnsupportedAggregateFunctionError,
88
} from "../../errors.js"
9-
import { compileExpression } from "./evaluators.js"
9+
import { compileExpression, toBooleanPredicate } from "./evaluators.js"
1010
import type {
1111
Aggregate,
1212
BasicExpression,
@@ -140,7 +140,7 @@ export function processGroupBy(
140140
filter(([, row]) => {
141141
// Create a namespaced row structure for HAVING evaluation
142142
const namespacedRow = { result: (row as any).__select_results }
143-
return compiledHaving(namespacedRow)
143+
return toBooleanPredicate(compiledHaving(namespacedRow))
144144
})
145145
)
146146
}
@@ -153,7 +153,7 @@ export function processGroupBy(
153153
filter(([, row]) => {
154154
// Create a namespaced row structure for functional HAVING evaluation
155155
const namespacedRow = { result: (row as any).__select_results }
156-
return fnHaving(namespacedRow)
156+
return toBooleanPredicate(fnHaving(namespacedRow))
157157
})
158158
)
159159
}
@@ -288,7 +288,7 @@ export function processGroupBy(
288288
filter(([, row]) => {
289289
// Create a namespaced row structure for functional HAVING evaluation
290290
const namespacedRow = { result: (row as any).__select_results }
291-
return fnHaving(namespacedRow)
291+
return toBooleanPredicate(fnHaving(namespacedRow))
292292
})
293293
)
294294
}

packages/db/src/query/compiler/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
UnsupportedFromTypeError,
1010
} from "../../errors.js"
1111
import { PropRef, Value as ValClass, getWhereExpression } from "../ir.js"
12-
import { compileExpression } from "./evaluators.js"
12+
import { compileExpression, toBooleanPredicate } from "./evaluators.js"
1313
import { processJoins } from "./joins.js"
1414
import { processGroupBy } from "./group-by.js"
1515
import { processOrderBy } from "./order-by.js"
@@ -195,7 +195,7 @@ export function compileQuery(
195195
const compiledWhere = compileExpression(whereExpression)
196196
pipeline = pipeline.pipe(
197197
filter(([_key, namespacedRow]) => {
198-
return compiledWhere(namespacedRow)
198+
return toBooleanPredicate(compiledWhere(namespacedRow))
199199
})
200200
)
201201
}
@@ -206,7 +206,7 @@ export function compileQuery(
206206
for (const fnWhere of query.fnWhere) {
207207
pipeline = pipeline.pipe(
208208
filter(([_key, namespacedRow]) => {
209-
return fnWhere(namespacedRow)
209+
return toBooleanPredicate(fnWhere(namespacedRow))
210210
})
211211
)
212212
}

0 commit comments

Comments
 (0)