From d09a056d54cc3d3408bc057b29e410b36307d83c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 5 Nov 2025 15:43:55 +0100 Subject: [PATCH 1/3] Modify operators to use 3-valued logic instead of classical boolean logic --- packages/db/src/collection/change-events.ts | 11 +- packages/db/src/query/compiler/evaluators.ts | 103 +++++- packages/db/src/query/compiler/group-by.ts | 8 +- packages/db/src/query/compiler/index.ts | 6 +- .../tests/query/compiler/evaluators.test.ts | 334 +++++++++++++++++- packages/db/tests/query/order-by.test.ts | 6 +- packages/db/tests/query/where.test.ts | 16 +- 7 files changed, 457 insertions(+), 27 deletions(-) diff --git a/packages/db/src/collection/change-events.ts b/packages/db/src/collection/change-events.ts index 10c05ae71..3f2e17035 100644 --- a/packages/db/src/collection/change-events.ts +++ b/packages/db/src/collection/change-events.ts @@ -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, @@ -199,7 +202,7 @@ export function createFilterFunction( const evaluator = compileSingleRowExpression(expression) const result = evaluator(item as Record) // 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 { @@ -211,7 +214,7 @@ export function createFilterFunction( }) as SingleRowRefProxy const result = whereCallback(simpleProxy) - return result + return toBooleanPredicate(result) } catch { // If both approaches fail, exclude the item return false @@ -232,7 +235,7 @@ export function createFilterFunctionFromExpression( try { const evaluator = compileSingleRowExpression(expression) const result = evaluator(item as Record) - return Boolean(result) + return toBooleanPredicate(result) } catch { // If evaluation fails, exclude the item return false diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 9304dfe2a..1b351c258 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -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 */ @@ -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 } } @@ -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 } } @@ -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 } } @@ -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 } } @@ -181,6 +220,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 } } @@ -188,25 +231,67 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { // 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 @@ -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 } @@ -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) } } @@ -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) } } diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index c37520b76..5101245bb 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -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, @@ -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)) }) ) } @@ -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)) }) ) } @@ -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)) }) ) } diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 621647206..36245f75e 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -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" @@ -195,7 +195,7 @@ export function compileQuery( const compiledWhere = compileExpression(whereExpression) pipeline = pipeline.pipe( filter(([_key, namespacedRow]) => { - return compiledWhere(namespacedRow) + return toBooleanPredicate(compiledWhere(namespacedRow)) }) ) } @@ -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)) }) ) } diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 1cb987321..6e36f5661 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -168,11 +168,33 @@ describe(`evaluators`, () => { }) it(`handles in with array`, () => { - const func = new Func(`in`, [new Value(2), new Value([1, 2, 3])]) + const func = new Func(`in`, [ + new Value(2), + new Value([1, 2, 3, null]), + ]) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) + + it(`handles in with null value (3-valued logic)`, () => { + const func = new Func(`in`, [new Value(null), new Value([1, 2, 3])]) + const compiled = compileExpression(func) + + // In 3-valued logic, null in array returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles in with undefined value (3-valued logic)`, () => { + const func = new Func(`in`, [ + new Value(undefined), + new Value([1, 2, 3]), + ]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined in array returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) }) describe(`math functions`, () => { @@ -257,6 +279,44 @@ describe(`evaluators`, () => { expect(compiled({})).toBe(true) }) + it(`handles like with null value (3-valued logic)`, () => { + const func = new Func(`like`, [new Value(null), new Value(`hello%`)]) + const compiled = compileExpression(func) + + // In 3-valued logic, like with null value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles like with undefined value (3-valued logic)`, () => { + const func = new Func(`like`, [ + new Value(undefined), + new Value(`hello%`), + ]) + const compiled = compileExpression(func) + + // In 3-valued logic, like with undefined value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles like with null pattern (3-valued logic)`, () => { + const func = new Func(`like`, [new Value(`hello`), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, like with null pattern returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles like with undefined pattern (3-valued logic)`, () => { + const func = new Func(`like`, [ + new Value(`hello`), + new Value(undefined), + ]) + const compiled = compileExpression(func) + + // In 3-valued logic, like with undefined pattern returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + it(`handles ilike (case insensitive)`, () => { const func = new Func(`ilike`, [ new Value(`HELLO`), @@ -276,6 +336,188 @@ describe(`evaluators`, () => { expect(compiled({})).toBe(true) }) + + it(`handles ilike with null value (3-valued logic)`, () => { + const func = new Func(`ilike`, [new Value(null), new Value(`hello%`)]) + const compiled = compileExpression(func) + + // In 3-valued logic, ilike with null value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles ilike with undefined value (3-valued logic)`, () => { + const func = new Func(`ilike`, [ + new Value(undefined), + new Value(`hello%`), + ]) + const compiled = compileExpression(func) + + // In 3-valued logic, ilike with undefined value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles ilike with null pattern (3-valued logic)`, () => { + const func = new Func(`ilike`, [new Value(`hello`), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, ilike with null pattern returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles ilike with undefined pattern (3-valued logic)`, () => { + const func = new Func(`ilike`, [ + new Value(`hello`), + new Value(undefined), + ]) + const compiled = compileExpression(func) + + // In 3-valued logic, ilike with undefined pattern returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + }) + + describe(`comparison operators`, () => { + describe(`eq (equality)`, () => { + it(`handles eq with null and null (3-valued logic)`, () => { + const func = new Func(`eq`, [new Value(null), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null = null returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles eq with null and value (3-valued logic)`, () => { + const func = new Func(`eq`, [new Value(null), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null = value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles eq with value and null (3-valued logic)`, () => { + const func = new Func(`eq`, [new Value(5), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, value = null returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles eq with undefined and value (3-valued logic)`, () => { + const func = new Func(`eq`, [new Value(undefined), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined = value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles eq with matching values`, () => { + const func = new Func(`eq`, [new Value(5), new Value(5)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + + it(`handles eq with non-matching values`, () => { + const func = new Func(`eq`, [new Value(5), new Value(10)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(false) + }) + }) + + describe(`gt (greater than)`, () => { + it(`handles gt with null and value (3-valued logic)`, () => { + const func = new Func(`gt`, [new Value(null), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null > value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles gt with value and null (3-valued logic)`, () => { + const func = new Func(`gt`, [new Value(5), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, value > null returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles gt with undefined (3-valued logic)`, () => { + const func = new Func(`gt`, [new Value(undefined), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined > value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles gt with valid values`, () => { + const func = new Func(`gt`, [new Value(10), new Value(5)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + }) + + describe(`gte (greater than or equal)`, () => { + it(`handles gte with null (3-valued logic)`, () => { + const func = new Func(`gte`, [new Value(null), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null >= value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles gte with undefined (3-valued logic)`, () => { + const func = new Func(`gte`, [new Value(undefined), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined >= value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + }) + + describe(`lt (less than)`, () => { + it(`handles lt with null (3-valued logic)`, () => { + const func = new Func(`lt`, [new Value(null), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null < value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles lt with undefined (3-valued logic)`, () => { + const func = new Func(`lt`, [new Value(undefined), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined < value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles lt with valid values`, () => { + const func = new Func(`lt`, [new Value(3), new Value(5)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + }) + + describe(`lte (less than or equal)`, () => { + it(`handles lte with null (3-valued logic)`, () => { + const func = new Func(`lte`, [new Value(null), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null <= value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + + it(`handles lte with undefined (3-valued logic)`, () => { + const func = new Func(`lte`, [new Value(undefined), new Value(5)]) + const compiled = compileExpression(func) + + // In 3-valued logic, undefined <= value returns UNKNOWN (null) + expect(compiled({})).toBe(null) + }) + }) }) describe(`boolean operators`, () => { @@ -289,6 +531,37 @@ describe(`evaluators`, () => { expect(compiled({})).toBe(false) }) + it(`handles and with null value (3-valued logic)`, () => { + const func = new Func(`and`, [new Value(true), new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, true AND null = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles and with undefined value (3-valued logic)`, () => { + const func = new Func(`and`, [new Value(true), new Value(undefined)]) + const compiled = compileExpression(func) + + // In 3-valued logic, true AND undefined = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles and with null and false (3-valued logic)`, () => { + const func = new Func(`and`, [new Value(null), new Value(false)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null AND false = false + expect(compiled({})).toBe(false) + }) + + it(`handles and with all true values`, () => { + const func = new Func(`and`, [new Value(true), new Value(true)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + it(`handles or with short-circuit evaluation`, () => { const func = new Func(`or`, [ new Value(true), @@ -299,7 +572,7 @@ describe(`evaluators`, () => { expect(compiled({})).toBe(true) }) - it(`handles or with all false values`, () => { + it(`handles or with null value (3-valued logic)`, () => { const func = new Func(`or`, [ new Value(false), new Value(0), @@ -307,8 +580,65 @@ describe(`evaluators`, () => { ]) const compiled = compileExpression(func) + // In 3-valued logic, false OR null = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles or with undefined value (3-valued logic)`, () => { + const func = new Func(`or`, [ + new Value(false), + new Value(0), + new Value(undefined), + ]) + const compiled = compileExpression(func) + // In 3-valued logic, false OR undefined = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles or with null and true (3-valued logic)`, () => { + const func = new Func(`or`, [new Value(null), new Value(true)]) + const compiled = compileExpression(func) + + // In 3-valued logic, null OR true = true + expect(compiled({})).toBe(true) + }) + + it(`handles or with all false values`, () => { + const func = new Func(`or`, [new Value(false), new Value(0)]) + const compiled = compileExpression(func) + expect(compiled({})).toBe(false) }) + + it(`handles not with null value (3-valued logic)`, () => { + const func = new Func(`not`, [new Value(null)]) + const compiled = compileExpression(func) + + // In 3-valued logic, NOT null = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles not with undefined value (3-valued logic)`, () => { + const func = new Func(`not`, [new Value(undefined)]) + const compiled = compileExpression(func) + + // In 3-valued logic, NOT undefined = null (UNKNOWN) + expect(compiled({})).toBe(null) + }) + + it(`handles not with true value`, () => { + const func = new Func(`not`, [new Value(true)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(false) + }) + + it(`handles not with false value`, () => { + const func = new Func(`not`, [new Value(false)]) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) }) }) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index f0cdf8307..acf6d5248 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -1350,7 +1350,7 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { expect(result1).toHaveLength(3) expect(result1.map((r) => r.age)).toEqual([14, 25, null]) - // The default compare options defaults to nulls first + // With 3-valued logic, lt(null, 18) returns UNKNOWN (null), which excludes the row const query2 = createLiveQueryCollection((q) => q .from({ persons: personsCollection }) @@ -1360,8 +1360,8 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { const result2 = Array.from(query2.values()) const ages = result2.map((r) => r.age) - expect(ages).toHaveLength(2) - expect(ages).toContain(null) + // null should NOT be included because lt(null, 18) returns UNKNOWN in 3-valued logic + expect(ages).toHaveLength(1) expect(ages).toContain(14) // The default compare options defaults to nulls first diff --git a/packages/db/tests/query/where.test.ts b/packages/db/tests/query/where.test.ts index 2eb78a38b..e68f04417 100644 --- a/packages/db/tests/query/where.test.ts +++ b/packages/db/tests/query/where.test.ts @@ -794,13 +794,13 @@ function createWhereTests(autoIndex: `off` | `eager`): void { employeesCollection = createEmployeesCollection() }) - test(`null equality comparison`, () => { + test(`isNull check`, () => { const nullEmails = createLiveQueryCollection({ startSync: true, query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => eq(emp.email, null)) + .where(({ emp }) => isNull(emp.email)) .select(({ emp }) => ({ id: emp.id, name: emp.name, @@ -816,7 +816,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => eq(emp.department_id, null)) + .where(({ emp }) => isNull(emp.department_id)) .select(({ emp }) => ({ id: emp.id, name: emp.name, @@ -828,13 +828,13 @@ function createWhereTests(autoIndex: `off` | `eager`): void { expect(nullDepartments.get(6)?.department_id).toBeNull() }) - test(`not null comparison`, () => { + test(`not isNull check`, () => { const hasEmail = createLiveQueryCollection({ startSync: true, query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => not(eq(emp.email, null))) + .where(({ emp }) => not(isNull(emp.email))) .select(({ emp }) => ({ id: emp.id, name: emp.name, @@ -850,7 +850,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => not(eq(emp.department_id, null))) + .where(({ emp }) => not(isNull(emp.department_id))) .select(({ emp }) => ({ id: emp.id, name: emp.name, @@ -1156,7 +1156,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => eq(emp.email, null)) + .where(({ emp }) => isNull(emp.email)) .select(({ emp }) => ({ id: emp.id, name: emp.name, @@ -1492,7 +1492,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { query: (q) => q .from({ emp: employeesCollection }) - .where(({ emp }) => eq(emp.contact?.address, null)) + .where(({ emp }) => isNull(emp.contact?.address)) .select(({ emp }) => ({ id: emp.id, name: emp.name, From 0bd94aa500016dc7dbd2b5ff34fa3a61c2f294e0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 5 Nov 2025 16:07:28 +0100 Subject: [PATCH 2/3] Changeset --- .changeset/whole-pants-strive.md | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/whole-pants-strive.md diff --git a/.changeset/whole-pants-strive.md b/.changeset/whole-pants-strive.md new file mode 100644 index 000000000..3c515cbec --- /dev/null +++ b/.changeset/whole-pants-strive.md @@ -0,0 +1,34 @@ +--- +"@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)) +) +``` \ No newline at end of file From 3179e3ce8bd1042da0d9b916567f2948cce25516 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 5 Nov 2025 16:19:39 +0100 Subject: [PATCH 3/3] Changeset --- .changeset/whole-pants-strive.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/whole-pants-strive.md b/.changeset/whole-pants-strive.md index 3c515cbec..dee498e44 100644 --- a/.changeset/whole-pants-strive.md +++ b/.changeset/whole-pants-strive.md @@ -10,11 +10,13 @@ Queries with null/undefined values now behave consistently with SQL databases, w **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)) ``` @@ -22,13 +24,15 @@ 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 }) => +q.from(...).where(({ person }) => or(lt(person.age, 18), isNull(person.age)) ) -``` \ No newline at end of file +```