From e8ef039f553096e29a5e502ba0396da45e71c998 Mon Sep 17 00:00:00 2001 From: Joshua <143388422+joshuablac@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:01:14 +0100 Subject: [PATCH] Evaluate UNION / EXCEPT / INTERSECT chains left to right to close #202 The parser nests a chain of set operations to the right, and each SELECT applied its operator to the full result of the rest of the chain, so A EXCEPT B EXCEPT C was computed as A EXCEPT (B EXCEPT C). In addition, (A EXCEPT B) UNION C lost the inner operation, because the outer clause was merged into the parenthesized SELECT and replaced or hid its own one. - Compile: the chain is flattened into a list of set operations applied left to right, with INTERSECT binding tighter than UNION and EXCEPT, as in SQL. A parenthesized operand stays a group. - Grammar: a parenthesized right operand is marked (parenthesizedOperand), and an outer set operation on a parenthesized SELECT that already has one is kept apart (outerSetOps) instead of overwriting it. - Execution: the four existing UNION ALL / UNION / EXCEPT / INTERSECT branches are moved unchanged into applySetOp(), called once per operation. A query with a single set operation runs the same code. test339 (9, 10, 12), test7 (I, J, K) and test2414 (P) asserted the old right-to-left results; they now expect the SQL results. Co-Authored-By: Claude Opus 5.5 --- src/28yy.js | 13 +++ src/38query.js | 236 +++++++++++++++++++++-------------------- src/40select.js | 113 +++++++++++++++----- src/alasqlparser.jison | 6 +- src/alasqlparser.js | 20 +++- test/test202-B.js | 100 +++++++++++++++++ test/test2414.js | 3 +- test/test339.js | 12 +-- test/test7.js | 23 ++-- 9 files changed, 361 insertions(+), 165 deletions(-) create mode 100644 test/test202-B.js diff --git a/src/28yy.js b/src/28yy.js index 429c017cbb..ab1f068170 100755 --- a/src/28yy.js +++ b/src/28yy.js @@ -27,6 +27,19 @@ var yy = { casesensitive: alasql.options.casesensitive, Base, + // Attach a UNION / UNION ALL / EXCEPT / INTERSECT clause to a parenthesized SELECT. + // If the SELECT already has its own set operation, as in (A EXCEPT B) UNION C, the + // outer clause is kept apart and applied after the inner one (#202). + extendSetOp: function (select, clause) { + if (!clause) return select; + if (select.union || select.unionall || select.except || select.intersect) { + select.outerSetOps = (select.outerSetOps || []).concat([clause]); + } else { + Object.assign(select, clause); + } + return select; + }, + // Helper for ParamValue handling in UPDATE/DELETE/INSERT compileParamValue: function (paramIndex, operation, needsSync, databaseid, self, refProp) { return function (params, cb) { diff --git a/src/38query.js b/src/38query.js index c0e5fa05e2..fd0500af00 100755 --- a/src/38query.js +++ b/src/38query.js @@ -82,8 +82,15 @@ function queryfn2(data, idx, query) { } function queryfn3(query) { + // i, j, ilen, r, ud and nd used to be hoisted from the set operation code now in applySetOp var scope = query.scope, - jlen; + jlen, + i, + j, + ilen, + r, + ud, + nd; // Preindexation of data sources preIndex(query); @@ -181,14 +188,14 @@ function queryfn3(query) { // apply ORDER BY and LIMIT to the first SELECT before combining. // This handles the pattern: SELECT ... ORDER BY ... LIMIT ... UNION ALL SELECT ... ORDER BY ... LIMIT ... // We only do this if the UNION branch also has ORDER BY/LIMIT (pattern 2), not if ORDER BY is at the end (pattern 1). - var unionBranchHasOrder = ['unionallfn', 'unionfn', 'exceptfn', 'intersectfn'].some( - function (fnName) { - var fn = query[fnName]; - return fn && fn.query && (fn.query.orderfn || fn.query.limit); - } + var firstSetOp = query.setops && query.setops[0]; + var unionBranchHasOrder = !!( + firstSetOp && + firstSetOp.fn.query && + (firstSetOp.fn.query.orderfn || firstSetOp.fn.query.limit) ); - if (unionBranchHasOrder && (query.orderfn || query.limit)) { + if (unionBranchHasOrder && !query.hasOuterSetOps && (query.orderfn || query.limit)) { // Apply ordering to first SELECT's data if (query.orderfn) { // Populate order keys before sorting (needed for UNION queries) @@ -224,116 +231,15 @@ function queryfn3(query) { } } - // UNION / UNION ALL - if (query.unionallfn) { - // TODO Simplify this part of program - var ud, nd; - if (query.corresponding) { - if (query.unionallfn.query && !query.unionallfn.query.modifier) - query.unionallfn.query.modifier = undefined; - ud = query.unionallfn(query.params); - } else { - if (query.unionallfn.query && !query.unionallfn.query.modifier) - query.unionallfn.query.modifier = 'RECORDSET'; - nd = query.unionallfn(query.params); - ud = []; - ilen = nd.data.length; - for (var i = 0; i < ilen; i++) { - var r = {}; - if (query.columns.length) { - jlen = Math.min(query.columns.length, nd.columns.length); - for (var j = 0; j < jlen; j++) { - r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - } else { - jlen = nd.columns.length; - for (var j = 0; j < jlen; j++) { - r[nd.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - } - ud.push(r); - } - } - query.data = query.data.concat(ud); - } else if (query.unionfn) { - if (query.corresponding) { - if (query.unionfn.query && !query.unionfn.query.modifier) - query.unionfn.query.modifier = 'ARRAY'; - ud = query.unionfn(query.params); - } else { - if (query.unionfn.query && !query.unionfn.query.modifier) - query.unionfn.query.modifier = 'RECORDSET'; - nd = query.unionfn(query.params); - ud = []; - ilen = nd.data.length; - for (var i = 0; i < ilen; i++) { - r = {}; - if (query.columns.length) { - jlen = Math.min(query.columns.length, nd.columns.length); - for (var j = 0; j < jlen; j++) { - r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - } else { - jlen = nd.columns.length; - for (var j = 0; j < jlen; j++) { - r[nd.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - } - ud.push(r); - } - } - - query.data = arrayUnionDeep(query.data, ud); - } else if (query.exceptfn) { - if (query.corresponding) { - if (query.exceptfn.query && !query.exceptfn.query.modifier) - query.exceptfn.query.modifier = 'ARRAY'; - var ud = query.exceptfn(query.params); - } else { - if (query.exceptfn.query && !query.exceptfn.query.modifier) - query.exceptfn.query.modifier = 'RECORDSET'; - var nd = query.exceptfn(query.params); - var ud = []; - for (var i = 0, ilen = nd.data.length; i < ilen; i++) { - var r = {}; - for (var j = Math.min(query.columns.length, nd.columns.length) - 1; 0 <= j; j--) { - r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - ud.push(r); - } - } - - query.data = arrayExceptDeep(query.data, ud); - } else if (query.intersectfn) { - if (query.corresponding) { - if (query.intersectfn.query && !query.intersectfn.query.modifier) - query.intersectfn.query.modifier = undefined; - ud = query.intersectfn(query.params); - } else { - if (query.intersectfn.query && !query.intersectfn.query.modifier) - query.intersectfn.query.modifier = 'RECORDSET'; - nd = query.intersectfn(query.params); - ud = []; - ilen = nd.data.length; - for (i = 0; i < ilen; i++) { - r = {}; - jlen = Math.min(query.columns.length, nd.columns.length); - for (j = 0; j < jlen; j++) { - r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; - } - ud.push(r); - } - } - - query.data = arrayIntersectDeep(query.data, ud); + // UNION / UNION ALL / EXCEPT / INTERSECT, applied from left to right + if (query.setops) { + query.setops.forEach(function (op) { + applySetOp(query, op.kind, op.fn, op.corresponding); + }); } // Populate order keys for UNION/INTERSECT/EXCEPT before ordering - if ( - query.orderfn && - query.orderColumns && - (query.unionallfn || query.unionfn || query.exceptfn || query.intersectfn) - ) { + if (query.orderfn && query.orderColumns && query.setops && query.setops.length) { for (i = 0, ilen = query.data.length; i < ilen; i++) { for (var idx = 0; idx < query.orderColumns.length; idx++) { var v = query.orderColumns[idx]; @@ -455,6 +361,108 @@ function queryfn3(query) { } // Limiting +/** + Apply one set operation (UNION, UNION ALL, EXCEPT, INTERSECT) to query.data, + with fn the compiled right operand (#202) + */ +function applySetOp(query, kind, fn, corresponding) { + var ud, nd, r, i, j, ilen, jlen; + if (kind === 'unionall') { + // TODO Simplify this part of program + var ud, nd; + if (corresponding) { + if (fn.query && !fn.query.modifier) fn.query.modifier = undefined; + ud = fn(query.params); + } else { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'RECORDSET'; + nd = fn(query.params); + ud = []; + ilen = nd.data.length; + for (var i = 0; i < ilen; i++) { + var r = {}; + if (query.columns.length) { + jlen = Math.min(query.columns.length, nd.columns.length); + for (var j = 0; j < jlen; j++) { + r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + } else { + jlen = nd.columns.length; + for (var j = 0; j < jlen; j++) { + r[nd.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + } + ud.push(r); + } + } + query.data = query.data.concat(ud); + } else if (kind === 'union') { + if (corresponding) { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'ARRAY'; + ud = fn(query.params); + } else { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'RECORDSET'; + nd = fn(query.params); + ud = []; + ilen = nd.data.length; + for (var i = 0; i < ilen; i++) { + r = {}; + if (query.columns.length) { + jlen = Math.min(query.columns.length, nd.columns.length); + for (var j = 0; j < jlen; j++) { + r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + } else { + jlen = nd.columns.length; + for (var j = 0; j < jlen; j++) { + r[nd.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + } + ud.push(r); + } + } + + query.data = arrayUnionDeep(query.data, ud); + } else if (kind === 'except') { + if (corresponding) { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'ARRAY'; + var ud = fn(query.params); + } else { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'RECORDSET'; + var nd = fn(query.params); + var ud = []; + for (var i = 0, ilen = nd.data.length; i < ilen; i++) { + var r = {}; + for (var j = Math.min(query.columns.length, nd.columns.length) - 1; 0 <= j; j--) { + r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + ud.push(r); + } + } + + query.data = arrayExceptDeep(query.data, ud); + } else if (kind === 'intersect') { + if (corresponding) { + if (fn.query && !fn.query.modifier) fn.query.modifier = undefined; + ud = fn(query.params); + } else { + if (fn.query && !fn.query.modifier) fn.query.modifier = 'RECORDSET'; + nd = fn(query.params); + ud = []; + ilen = nd.data.length; + for (i = 0; i < ilen; i++) { + r = {}; + jlen = Math.min(query.columns.length, nd.columns.length); + for (j = 0; j < jlen; j++) { + r[query.columns[j].columnid] = nd.data[i][nd.columns[j].columnid]; + } + ud.push(r); + } + } + + query.data = arrayIntersectDeep(query.data, ud); + } +} + function doLimit(query) { // console.log(query.limit, query.offset) if (query.limit) { diff --git a/src/40select.js b/src/40select.js index 87f83a32cf..a2b927fe48 100755 --- a/src/40select.js +++ b/src/40select.js @@ -12,6 +12,57 @@ /* global yy */ +/* + Set operations (UNION, UNION ALL, EXCEPT, INTERSECT) of a SELECT (#202). + The parser nests a chain to the right: A op1 B op2 C gives A.op1 = B and B.op2 = C. + The helpers below flatten it, so the chain is evaluated left to right with INTERSECT + binding tighter than UNION and EXCEPT, as in SQL. A parenthesized operand + (parenthesizedOperand) keeps its own set operations as a group. +*/ +const SET_OP_KINDS = ['union', 'unionall', 'except', 'intersect']; + +// The set operation of a SELECT (or of a parsed UNION clause) and its right operand +const setOpOf = sel => { + const kind = SET_OP_KINDS.find(k => sel[k]); + return kind && {kind, select: sel[kind], corresponding: !!sel.corresponding}; +}; + +// The set operations of a chain, in order, up to a parenthesized operand +const setOpChain = sel => { + const list = []; + for (let op = setOpOf(sel); op;) { + list.push(op); + op = op.select.parenthesizedOperand ? undefined : setOpOf(op.select); + } + return list; +}; + +// An operand without the set operations that belong to the enclosing chain +const setOpOperand = sel => { + if (sel.parenthesizedOperand || !setOpOf(sel)) return sel; + const operand = new yy.Select(Object.assign({}, sel)); + SET_OP_KINDS.forEach(k => delete operand[k]); + delete operand.corresponding; + return operand; +}; + +// INTERSECT binds tighter: it joins the right operand of the preceding UNION or EXCEPT +const bindIntersects = list => { + const out = []; + let last; + list.forEach(op => { + if (op.kind === 'intersect' && last) { + last.intersects.push(op); + } else if (op.kind === 'intersect') { + out.push(op); + } else { + last = {...op, intersects: []}; + out.push(last); + } + }); + return out; +}; + yy.Select = class Select { constructor(params) { Object.assign(this, params); @@ -152,6 +203,35 @@ yy.Select = class Select { } // Compile SELECT statement + /** + Compile the set operations of this SELECT into a list of {kind, corresponding, fn}, + applied to its result from left to right (#202) + */ + compileSetOps(databaseid) { + // A parenthesized SELECT can carry outer set operations, as in (A EXCEPT B) UNION C, + // and an operand grouped by INTERSECT carries the INTERSECTs bound to it + const levels = [setOpChain(this)]; + (this.outerSetOps || []).forEach(clause => levels.push(setOpChain(clause))); + if (this.extraSetOps) levels.push(this.extraSetOps); + + const setops = []; + levels.forEach(level => { + bindIntersects(level).forEach(op => { + let operand = setOpOperand(op.select); + if (op.intersects && op.intersects.length) { + operand = new yy.Select(Object.assign({}, operand)); + operand.extraSetOps = op.intersects; + } + setops.push({ + kind: op.kind, + corresponding: op.corresponding, + fn: operand.compile(databaseid), + }); + }); + }); + return setops; + } + compile(databaseid, params) { var db = alasql.databases[databaseid]; // Create variable for query @@ -267,32 +347,15 @@ yy.Select = class Select { query.percent = this.percent; - // 9. Compile ordering function for UNION and UNIONALL + // 9. Compile set operations (UNION, UNION ALL, EXCEPT, INTERSECT) query.corresponding = this.corresponding; // If CORRESPONDING flag exists - if (this.union) { - query.unionfn = this.union.compile(databaseid); - // ORDER BY is now at the top level, not in the union clause - if (!query.orderfn && this.union.order) { - query.orderfn = this.union.compileOrder(query, params); - } - } else if (this.unionall) { - query.unionallfn = this.unionall.compile(databaseid); - // ORDER BY is now at the top level, not in the unionall clause - if (!query.orderfn && this.unionall.order) { - query.orderfn = this.unionall.compileOrder(query, params); - } - } else if (this.except) { - query.exceptfn = this.except.compile(databaseid); - // ORDER BY is now at the top level, not in the except clause - if (!query.orderfn && this.except.order) { - query.orderfn = this.except.compileOrder(query, params); - } - } else if (this.intersect) { - query.intersectfn = this.intersect.compile(databaseid); - // ORDER BY is now at the top level, not in the intersect clause - if (!query.orderfn && this.intersect.order) { - query.orderfn = this.intersect.compileOrder(query, params); - } + query.setops = this.compileSetOps(databaseid); + // With (A op B) op C ... the ORDER BY / LIMIT after the chain is for the whole result + query.hasOuterSetOps = !!(this.outerSetOps && this.outerSetOps.length); + // ORDER BY of the right operand is used at the top level when the query has none + const setOpRight = this.union || this.unionall || this.except || this.intersect; + if (setOpRight && !query.orderfn && setOpRight.order) { + query.orderfn = setOpRight.compileOrder(query, params); } // SELECT INTO diff --git a/src/alasqlparser.jison b/src/alasqlparser.jison index 4876cb8bd6..75579f1ada 100755 --- a/src/alasqlparser.jison +++ b/src/alasqlparser.jison @@ -546,7 +546,7 @@ Select | LPAR Select RPAR UnionClause OrderClause LimitClause { $$ = $2; - yy.extend($$,$4); + yy.extendSetOp($$,$4); yy.extend($$,$5); yy.extend($$,$6); if(yy.exists) $$.exists = yy.exists.slice(); /* if(yy.queries) $$.queries = yy.queries; @@ -554,7 +554,7 @@ Select */ } | ParenthesizedSelect UnionClause OrderClause LimitClause { - yy.extend($$,$1); yy.extend($$,$2); yy.extend($$,$3); yy.extend($$,$4); + yy.extend($$,$1); yy.extendSetOp($1,$2); yy.extend($$,$3); yy.extend($$,$4); $$ = $1; if(yy.exists) $$.exists = yy.exists.slice(); } @@ -1222,6 +1222,8 @@ UnionableSelect } yy.queries = yy.queriesStack.pop(); } + // A parenthesized operand keeps its own set operations as a group (#202) + $1.parenthesizedOperand = true; $$ = $1; } ; diff --git a/src/alasqlparser.js b/src/alasqlparser.js index a370ee2610..0cc3b78633 100755 --- a/src/alasqlparser.js +++ b/src/alasqlparser.js @@ -165,7 +165,7 @@ break; case 81: this.$ = $$[$0-4]; - yy.extend(this.$,$$[$0-2]); + yy.extendSetOp(this.$,$$[$0-2]); yy.extend(this.$,$$[$0-1]); yy.extend(this.$,$$[$0]); if(yy.exists) this.$.exists = yy.exists.slice(); /* if(yy.queries) this.$.queries = yy.queries; @@ -174,7 +174,7 @@ case 81: break; case 82: - yy.extend(this.$,$$[$0-3]); yy.extend(this.$,$$[$0-2]); yy.extend(this.$,$$[$0-1]); yy.extend(this.$,$$[$0]); + yy.extend(this.$,$$[$0-3]); yy.extendSetOp($$[$0-3],$$[$0-2]); yy.extend(this.$,$$[$0-1]); yy.extend(this.$,$$[$0]); this.$ = $$[$0-3]; if(yy.exists) this.$.exists = yy.exists.slice(); @@ -711,7 +711,19 @@ case 260: this.$ = {op: 'intersect', corresponding: true}; break; -case 261: case 262: +case 261: + + // Restore parent queries from stack and assign current queries to nested SELECT + if(yy.queriesStack && yy.queriesStack.length > 0) { + if(yy.queries && yy.queries.length > 0) { + $$[$0].queries = yy.queries; + } + yy.queries = yy.queriesStack.pop(); + } + this.$ = $$[$0]; + +break; +case 262: // Restore parent queries from stack and assign current queries to nested SELECT if(yy.queriesStack && yy.queriesStack.length > 0) { @@ -720,6 +732,8 @@ case 261: case 262: } yy.queries = yy.queriesStack.pop(); } + // A parenthesized operand keeps its own set operations as a group (#202) + $$[$0].parenthesizedOperand = true; this.$ = $$[$0]; break; diff --git a/test/test202-B.js b/test/test202-B.js new file mode 100644 index 0000000000..6210d54c2c --- /dev/null +++ b/test/test202-B.js @@ -0,0 +1,100 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +let testId = '202'; // Chains of UNION / EXCEPT / INTERSECT + +describe(`Test ${testId} B - set operation chains are evaluated left to right`, function () { + // e1 = {1,2,3,4}, e2 = {1,5}, e3 = {3,5}, e4 = {2,5} + before(function () { + alasql('create database test202b'); + alasql('use test202b'); + alasql('CREATE TABLE e1 (a INT); INSERT INTO e1 VALUES (1),(2),(3),(4)'); + alasql('CREATE TABLE e2 (a INT); INSERT INTO e2 VALUES (1),(5)'); + alasql('CREATE TABLE e3 (a INT); INSERT INTO e3 VALUES (3),(5)'); + alasql('CREATE TABLE e4 (a INT); INSERT INTO e4 VALUES (2),(5)'); + }); + + after(function () { + alasql('drop database test202b'); + }); + + const S = t => `SELECT a FROM ${t}`; + const values = sql => + alasql(sql) + .map(r => r.a) + .sort((x, y) => x - y); + + it('A) The example from the issue: SELECT * ... EXCEPT ... EXCEPT', function () { + assert.deepStrictEqual( + values('SELECT * FROM e1 EXCEPT SELECT * FROM e2 EXCEPT SELECT * FROM e3'), + [2, 4] + ); + }); + + it('B) EXCEPT and UNION have the same precedence, left to right', function () { + assert.deepStrictEqual(values(`${S('e1')} UNION ${S('e2')} EXCEPT ${S('e3')}`), [1, 2, 4]); + assert.deepStrictEqual(values(`${S('e1')} EXCEPT ${S('e2')} UNION ${S('e3')}`), [2, 3, 4, 5]); + assert.deepStrictEqual( + values(`${S('e1')} EXCEPT ${S('e2')} EXCEPT ${S('e3')} EXCEPT ${S('e4')}`), + [4] + ); + assert.deepStrictEqual(values(`${S('e2')} UNION ALL ${S('e3')} EXCEPT ${S('e4')}`), [1, 3]); + assert.deepStrictEqual(values(`${S('e1')} UNION ${S('e2')} UNION ${S('e3')}`), [1, 2, 3, 4, 5]); + assert.deepStrictEqual( + values(`${S('e2')} UNION ALL ${S('e3')} UNION ALL ${S('e4')}`), + [1, 2, 3, 5, 5, 5] + ); + }); + + it('C) INTERSECT binds tighter than UNION and EXCEPT', function () { + assert.deepStrictEqual(values(`${S('e1')} INTERSECT ${S('e2')} UNION ${S('e3')}`), [1, 3, 5]); + assert.deepStrictEqual( + values(`${S('e1')} UNION ${S('e2')} INTERSECT ${S('e3')}`), + [1, 2, 3, 4, 5] + ); + assert.deepStrictEqual( + values(`${S('e1')} EXCEPT ${S('e2')} INTERSECT ${S('e3')}`), + [1, 2, 3, 4] + ); + assert.deepStrictEqual(values(`${S('e1')} INTERSECT ${S('e2')} EXCEPT ${S('e3')}`), [1]); + assert.deepStrictEqual( + values(`${S('e1')} UNION ${S('e2')} INTERSECT ${S('e3')} EXCEPT ${S('e4')}`), + [1, 3, 4] + ); + assert.deepStrictEqual(values(`${S('e1')} INTERSECT ${S('e4')} INTERSECT ${S('e2')}`), []); + }); + + it('D) Parentheses group set operations', function () { + assert.deepStrictEqual(values(`(${S('e1')} EXCEPT ${S('e2')}) EXCEPT ${S('e3')}`), [2, 4]); + assert.deepStrictEqual(values(`${S('e1')} EXCEPT (${S('e2')} EXCEPT ${S('e3')})`), [2, 3, 4]); + assert.deepStrictEqual(values(`(${S('e1')} EXCEPT ${S('e2')}) UNION ${S('e3')}`), [2, 3, 4, 5]); + assert.deepStrictEqual(values(`(${S('e1')} UNION ${S('e2')}) INTERSECT ${S('e3')}`), [3, 5]); + assert.deepStrictEqual( + values(`((${S('e1')} EXCEPT ${S('e2')}) EXCEPT ${S('e3')}) UNION ${S('e4')}`), + [2, 4, 5] + ); + }); + + it('E) ORDER BY applies to the result of the whole chain', function () { + assert.deepStrictEqual( + alasql(`${S('e1')} EXCEPT ${S('e2')} EXCEPT ${S('e3')} ORDER BY a DESC`), + [{a: 4}, {a: 2}] + ); + assert.deepStrictEqual( + alasql(`(${S('e1')} EXCEPT ${S('e2')}) UNION ${S('e3')} ORDER BY a DESC`), + [{a: 5}, {a: 4}, {a: 3}, {a: 2}] + ); + }); + + it('F) CORRESPONDING in a chain', function () { + alasql('CREATE TABLE cx (a INT, b STRING); INSERT INTO cx VALUES (1,"p"),(2,"q"),(3,"r")'); + alasql('CREATE TABLE cy (b STRING, a INT); INSERT INTO cy VALUES ("p",1)'); + alasql('CREATE TABLE cz (b STRING, a INT); INSERT INTO cz VALUES ("r",3)'); + const res = alasql( + 'SELECT a, b FROM cx EXCEPT CORRESPONDING SELECT b, a FROM cy EXCEPT CORRESPONDING SELECT b, a FROM cz' + ); + assert.deepStrictEqual(res, [{a: 2, b: 'q'}]); + }); +}); diff --git a/test/test2414.js b/test/test2414.js index 1d5c10af35..d613224327 100644 --- a/test/test2414.js +++ b/test/test2414.js @@ -283,6 +283,7 @@ describe('Test 2414 - UNION with parenthesized SELECT and ORDER BY', function () [data, data, data] ); - assert.deepEqual(res, [{a: 4}, {a: 3}, {a: 1}]); + // The inner UNION with a = 2 is kept (#202): {1, 2} UNION {3, 4}, ordered DESC + assert.deepEqual(res, [{a: 4}, {a: 3}, {a: 2}, {a: 1}]); }); }); diff --git a/test/test339.js b/test/test339.js index 30cea7c9d3..dcfdf88ea9 100644 --- a/test/test339.js +++ b/test/test339.js @@ -105,8 +105,8 @@ insert into c (col) values (1), (2), (5); except select col from c; `); - // console.log(res); - assert.deepStrictEqual(res.sort(), [1, 2, 3, 4]); + // (a UNION b) EXCEPT c, evaluated left to right (#202) + assert.deepStrictEqual(res.sort(), [3, 4]); done(); }); @@ -118,8 +118,8 @@ insert into c (col) values (1), (2), (5); union select col from c; `); - // console.log(res); - assert.deepStrictEqual(res.sort(), [3]); + // (a EXCEPT b) UNION c, evaluated left to right (#202) + assert.deepStrictEqual(res.sort(), [1, 2, 3, 5]); done(); }); @@ -144,8 +144,8 @@ insert into c (col) values (1), (2), (5); union select col from c; `); - // console.log(res); - assert.deepStrictEqual(res.sort(), [1, 2]); + // (a INTERSECT b) UNION c (#202) + assert.deepStrictEqual(res.sort(), [1, 2, 5]); done(); }); diff --git a/test/test7.js b/test/test7.js index d410c8c529..760cb75724 100644 --- a/test/test7.js +++ b/test/test7.js @@ -90,13 +90,10 @@ describe('Test 7 - ORDER BY on multiple UNIONs', function () { var sql = 'SELECT 10 AS a UNION ALL SELECT 20 AS a UNION SELECT 10 AS a UNION ALL SELECT 30 AS a ORDER BY a ASC'; var res = alasql(sql); - // The UNION operator only applies between adjacent selects in the chain - // Structure: ((10 UNION ALL 20) UNION 10) UNION ALL 30 + // Structure: ((10 UNION ALL 20) UNION 10) UNION ALL 30, evaluated left to right (#202) // First: [10, 20] UNION 10 = [10, 20] (UNION removes duplicate 10) // Then: [10, 20] UNION ALL 30 = [10, 20, 30] - // But the actual result shows [10, 10, 20, 30] - the UNION didn't remove the duplicate - // This appears to be the current behavior - var expected = [{a: 10}, {a: 10}, {a: 20}, {a: 30}]; + var expected = [{a: 10}, {a: 20}, {a: 30}]; assert.deepStrictEqual(res, expected, 'Mixed UNION and UNION ALL with ORDER BY'); }); @@ -104,11 +101,10 @@ describe('Test 7 - ORDER BY on multiple UNIONs', function () { var sql = 'SELECT 10 AS a UNION ALL SELECT 20 AS a INTERSECT SELECT 20 AS a UNION ALL SELECT 30 AS a ORDER BY a DESC'; var res = alasql(sql); - // Structure: ((10 UNION ALL 20) INTERSECT 20) UNION ALL 30 - // First: [10, 20] INTERSECT [20] = [20] - // Then: [20] UNION ALL [30] = [20, 30] - // But ordered DESC, so [20, 10] based on actual result - var expected = [{a: 20}, {a: 10}]; + // INTERSECT binds tighter than UNION (SQL standard, #202) + // Structure: (10 UNION ALL (20 INTERSECT 20)) UNION ALL 30 = [10, 20, 30] + // Ordered DESC: [30, 20, 10] + var expected = [{a: 30}, {a: 20}, {a: 10}]; assert.deepStrictEqual(res, expected, 'INTERSECT with ORDER BY'); }); @@ -116,10 +112,9 @@ describe('Test 7 - ORDER BY on multiple UNIONs', function () { var sql = 'SELECT 10 AS a UNION ALL SELECT 20 AS a UNION ALL SELECT 30 AS a EXCEPT SELECT 20 AS a ORDER BY a DESC'; var res = alasql(sql); - // Structure: (10 UNION ALL 20 UNION ALL 30) EXCEPT 20 - // First: [10, 20, 30] EXCEPT [20] = [10, 30]... but result shows [30, 20, 10] - // It seems EXCEPT isn't working as expected, keeping all values - var expected = [{a: 30}, {a: 20}, {a: 10}]; + // Structure: (10 UNION ALL 20 UNION ALL 30) EXCEPT 20, evaluated left to right (#202) + // [10, 20, 30] EXCEPT [20] = [10, 30], ordered DESC: [30, 10] + var expected = [{a: 30}, {a: 10}]; assert.deepStrictEqual(res, expected, 'EXCEPT with ORDER BY'); });