From 14f409f2c4094a52c0610f88f811542e9e95cc0a Mon Sep 17 00:00:00 2001 From: Joshua <143388422+joshuablac@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:17:47 +0100 Subject: [PATCH 1/2] Fix NOT IN with NULL in the subquery or list to close #1414 In SQL, x NOT IN (...) is UNKNOWN when x is not found and the values contain NULL, so such rows are filtered out. AlaSQL returned true, e.g. SELECT a FROM R WHERE a NOT IN (SELECT b FROM S) returned a = 1 when S holds a NULL. A utils.notIn(set, value) helper now returns false when the value is found, undefined (NULL) when it is not found but the set contains NULL (null, undefined or NaN, like the operator null check), otherwise true. It is used by the cached and correlated subquery paths and by both literal list paths of NOT IN. IN is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/15utility.js | 11 +++++++ src/50expression.js | 8 ++--- test/test1414.js | 75 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 test/test1414.js diff --git a/src/15utility.js b/src/15utility.js index 5f2c40559b..c898015534 100755 --- a/src/15utility.js +++ b/src/15utility.js @@ -1090,6 +1090,17 @@ var getValueOf = (utils.getValueOf = function (val) { : val; }); +/** + * Result of `value NOT IN (set)` with SQL three-valued logic (#1414): + * false when the value is in the set, NULL (undefined) when it is not but the + * set contains NULL (null, undefined or NaN, as in the operator null check), + * otherwise true. + */ +utils.notIn = function (set, value) { + if (set.has(value)) return false; + return set.has(null) || set.has(undefined) || set.has(NaN) ? undefined : true; +}; + /** Flat array by first row */ diff --git a/src/50expression.js b/src/50expression.js index 37ddb887d6..01d3c607aa 100755 --- a/src/50expression.js +++ b/src/50expression.js @@ -406,8 +406,8 @@ // If correlated, we cannot cache the results as they depend on the current row const cacheKey = `notIn${this.queriesidx}`; const checkCorrelated = `(this.queriesfn[${this.queriesidx}].query && this.queriesfn[${this.queriesidx}].query.isCorrelated)`; - const cachedLookup = `(!(this.subqueryCache = this.subqueryCache || {}, this.subqueryCache.${cacheKey} || (this.subqueryCache.${cacheKey} = new Set(alasql.utils.flatArray(this.queriesfn[${this.queriesidx}](params, null, ${context})).map(alasql.utils.getValueOf)))).has(alasql.utils.getValueOf(${leftJS()})))`; - const uncachedLookup = `(alasql.utils.flatArray(this.queriesfn[${this.queriesidx}](params, null, ${context})).indexOf(alasql.utils.getValueOf(${leftJS()})) < 0)`; + const cachedLookup = `alasql.utils.notIn((this.subqueryCache = this.subqueryCache || {}, this.subqueryCache.${cacheKey} || (this.subqueryCache.${cacheKey} = new Set(alasql.utils.flatArray(this.queriesfn[${this.queriesidx}](params, null, ${context})).map(alasql.utils.getValueOf)))), alasql.utils.getValueOf(${leftJS()}))`; + const uncachedLookup = `alasql.utils.notIn(new Set(alasql.utils.flatArray(this.queriesfn[${this.queriesidx}](params, null, ${context})).map(alasql.utils.getValueOf)), alasql.utils.getValueOf(${leftJS()}))`; s = `(${checkCorrelated} ? ${uncachedLookup} : ${cachedLookup})`; } else if (Array.isArray(this.right)) { // Empty array: everything is NOT IN an empty set, always true @@ -422,14 +422,14 @@ this.right.some(value => value instanceof yy.ParamValue) ) { // Leverage JS Set for faster lookups than arrays - s = `(!(new Set([${this.right.map(ref).join(',')}]).has(alasql.utils.getValueOf(${leftJS()}))))`; + s = `alasql.utils.notIn(new Set([${this.right.map(ref).join(',')}]), alasql.utils.getValueOf(${leftJS()}))`; } else { // Use a cache to avoid re-creating the Set on every identical query alasql.sets = alasql.sets || {}; const allValues = this.right.map(value => value.value); const allValuesStr = allValues.join(','); alasql.sets[allValuesStr] = alasql.sets[allValuesStr] || new Set(allValues); - s = `!alasql.sets["${allValuesStr}"].has(alasql.utils.getValueOf(${leftJS()}))`; + s = `alasql.utils.notIn(alasql.sets["${allValuesStr}"], alasql.utils.getValueOf(${leftJS()}))`; } } else { s = `(${rightJS()}.indexOf(${leftJS()}) === -1)`; diff --git a/test/test1414.js b/test/test1414.js new file mode 100644 index 0000000000..7bd02a927e --- /dev/null +++ b/test/test1414.js @@ -0,0 +1,75 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +let testId = '1414'; // NOT IN with NULL in the subquery or list + +describe(`Test ${testId} - NOT IN with NULL values follows SQL semantics`, function () { + before(function () { + alasql('create database test' + testId); + alasql('use test' + testId); + }); + + after(function () { + alasql('drop database test' + testId); + }); + + it('A) Example from the issue', function () { + alasql('CREATE TABLE R (a number)'); + alasql('CREATE TABLE S (b number)'); + alasql.tables.R.data = [{a: 1}, {a: null}]; + alasql.tables.S.data = [{b: null}]; + const res = alasql('select a from R where a not in (select b from S)'); + assert.deepStrictEqual(res, []); + }); + + const R = [{a: 1}, {a: 2}, {a: null}]; + + it('B) NOT IN subquery containing NULL and a value', function () { + const res = alasql('SELECT a FROM ? WHERE a NOT IN (SELECT b FROM ?)', [ + R, + [{b: null}, {b: 2}], + ]); + assert.deepStrictEqual(res, []); + }); + + it('C) Correlated NOT IN subquery containing NULL', function () { + alasql('CREATE TABLE R2 (id INT, a INT)'); + alasql('CREATE TABLE S2 (id INT, b INT)'); + alasql('INSERT INTO R2 VALUES (1, 1), (2, 1)'); + alasql('INSERT INTO S2 VALUES (1, NULL), (2, 5)'); + const res = alasql('SELECT id FROM R2 WHERE a NOT IN (SELECT b FROM S2 WHERE S2.id = R2.id)'); + assert.deepStrictEqual(res, [{id: 2}]); + }); + + it('D) NOT IN list containing NULL', function () { + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a NOT IN (2, NULL)', [R]), []); + }); + + it('E) The value of NOT IN is NULL (undefined) when nothing matches and the set has NULL', function () { + assert.deepStrictEqual(alasql('SELECT 1 NOT IN (SELECT b FROM ?) AS x', [[{b: null}]]), [ + {x: undefined}, + ]); + assert.deepStrictEqual( + alasql('SELECT 2 NOT IN (SELECT b FROM ?) AS x', [[{b: null}, {b: 2}]]), + [{x: false}] + ); + assert.deepStrictEqual(alasql('SELECT 1 NOT IN (2, NULL) AS x'), [{x: undefined}]); + }); + + it('F) Without NULL in the set NOT IN and IN are unchanged', function () { + const S = [{b: 2}, {b: 3}]; + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a NOT IN (SELECT b FROM ?)', [R, S]), [ + {a: 1}, + ]); + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a NOT IN (2, 3)', [R]), [{a: 1}]); + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a IN (SELECT b FROM ?)', [R, S]), [ + {a: 2}, + ]); + assert.deepStrictEqual( + alasql('SELECT a FROM ? WHERE a IN (SELECT b FROM ?)', [R, [{b: null}, {b: 2}]]), + [{a: 2}] + ); + }); +}); From c306cf2dcd412b1fcfc2252c0e4c2482cd2636fb Mon Sep 17 00:00:00 2001 From: Joshua <143388422+joshuablac@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:28:08 +0100 Subject: [PATCH 2/2] Key the cached IN / NOT IN value sets by type and value The cache of literal value lists was keyed by values.join(','), so lists like (2, NULL) and (2, '') or (1, 3) and ('1', 3) shared one Set. With NOT IN now treating a NULL in the set as UNKNOWN, a NOT IN (2, '') run after a IN (2, NULL) filtered out every row. The key now includes the type of each value, and is quoted with JSON.stringify in the generated code. Co-Authored-By: Claude Opus 5.5 --- src/50expression.js | 10 ++++++---- test/test1414.js | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/50expression.js b/src/50expression.js index 01d3c607aa..65e744f5e9 100755 --- a/src/50expression.js +++ b/src/50expression.js @@ -393,9 +393,10 @@ // Use a cache to avoid re-creating the Set on every identical query alasql.sets = alasql.sets || {}; const allValues = this.right.map(value => value.value); - const allValuesStr = allValues.join(','); + // Key by type and value, so lists like (2, NULL) and (2, '') or (1) and ('1') don't share a Set + const allValuesStr = JSON.stringify(allValues.map(v => [typeof v, v])); alasql.sets[allValuesStr] = alasql.sets[allValuesStr] || new Set(allValues); - s = `alasql.sets["${allValuesStr}"].has(alasql.utils.getValueOf(${leftJS()}))`; + s = `alasql.sets[${JSON.stringify(allValuesStr)}].has(alasql.utils.getValueOf(${leftJS()}))`; } } else { s = `(${rightJS()}.indexOf(${leftJS()}) > -1)`; @@ -427,9 +428,10 @@ // Use a cache to avoid re-creating the Set on every identical query alasql.sets = alasql.sets || {}; const allValues = this.right.map(value => value.value); - const allValuesStr = allValues.join(','); + // Key by type and value, so lists like (2, NULL) and (2, '') or (1) and ('1') don't share a Set + const allValuesStr = JSON.stringify(allValues.map(v => [typeof v, v])); alasql.sets[allValuesStr] = alasql.sets[allValuesStr] || new Set(allValues); - s = `alasql.utils.notIn(alasql.sets["${allValuesStr}"], alasql.utils.getValueOf(${leftJS()}))`; + s = `alasql.utils.notIn(alasql.sets[${JSON.stringify(allValuesStr)}], alasql.utils.getValueOf(${leftJS()}))`; } } else { s = `(${rightJS()}.indexOf(${leftJS()}) === -1)`; diff --git a/test/test1414.js b/test/test1414.js index 7bd02a927e..7c0de68ccc 100644 --- a/test/test1414.js +++ b/test/test1414.js @@ -72,4 +72,16 @@ describe(`Test ${testId} - NOT IN with NULL values follows SQL semantics`, funct [{a: 2}] ); }); + + it('G) Cached value lists that only differ by NULL, empty string or type are kept apart', function () { + const rows = [{a: 1}, {a: 2}, {a: ''}, {a: '1'}]; + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a IN (2, NULL)', [rows]), [{a: 2}]); + assert.deepStrictEqual(alasql("SELECT a FROM ? WHERE a NOT IN (2, '')", [rows]), [ + {a: 1}, + {a: '1'}, + ]); + assert.deepStrictEqual(alasql("SELECT a FROM ? WHERE a IN (2, '')", [rows]), [{a: 2}, {a: ''}]); + assert.deepStrictEqual(alasql('SELECT a FROM ? WHERE a IN (1, 3)', [rows]), [{a: 1}]); + assert.deepStrictEqual(alasql("SELECT a FROM ? WHERE a IN ('1', 3)", [rows]), [{a: '1'}]); + }); });