From e4088e8a344f822f147cf3e3505462fdc3fab81d Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Mon, 16 Feb 2026 16:55:12 +0530 Subject: [PATCH 1/3] Implement LEAD, LAG, FIRST_VALUE, LAST_VALUE window functions (#2409) Add support for SQL:2003 positional window functions with PARTITION BY and ORDER BY. These enable period-over-period comparisons and accessing relative row values within partitions. --- src/40select.js | 78 +++++++++++++++ src/424select.js | 47 +++++++++ src/55functions.js | 12 +++ test/test2409.js | 239 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 test/test2409.js diff --git a/src/40select.js b/src/40select.js index e55a49474d..5b5e23f06e 100755 --- a/src/40select.js +++ b/src/40select.js @@ -187,6 +187,7 @@ yy.Select = class Select { query.rownums = []; query.grouprownums = []; query.windowaggrs = []; // For window aggregate functions (COUNT/MAX/MIN/SUM/AVG with OVER) + query.windowfns = []; // For positional window functions (LEAD/LAG/FIRST_VALUE/LAST_VALUE) // Check if INTO OBJECT() is used - this affects how arrow expressions are compiled if (this.into instanceof yy.FuncValue && this.into.funcid.toUpperCase() === 'OBJECT') { @@ -509,6 +510,83 @@ yy.Select = class Select { } } + // Handle positional window functions - LEAD/LAG/FIRST_VALUE/LAST_VALUE + if (query.windowfns && query.windowfns.length > 0) { + for (var j = 0, jlen = query.windowfns.length; j < jlen; j++) { + var wfConfig = query.windowfns[j]; + var partitions = {}; + + // Group rows by partition key + for (var i = 0, ilen = res.length; i < ilen; i++) { + var partitionKey = + wfConfig.partitionColumns && wfConfig.partitionColumns.length > 0 + ? wfConfig.partitionColumns + .map(function (col) { + return res[i][col]; + }) + .join('|') + : '__all__'; + + if (!partitions[partitionKey]) partitions[partitionKey] = []; + partitions[partitionKey].push(i); + } + + // Process each partition + for (var partitionKey in partitions) { + var rowIndices = partitions[partitionKey]; + + // Sort row indices within partition by ORDER BY columns + if (wfConfig.orderColumns && wfConfig.orderColumns.length > 0) { + rowIndices.sort(function (a, b) { + for (var oi = 0; oi < wfConfig.orderColumns.length; oi++) { + var ocol = wfConfig.orderColumns[oi]; + var va = res[a][ocol.columnid]; + var vb = res[b][ocol.columnid]; + if (va == null && vb == null) continue; + if (va == null) return ocol.direction === 'ASC' ? -1 : 1; + if (vb == null) return ocol.direction === 'ASC' ? 1 : -1; + if (va < vb) return ocol.direction === 'ASC' ? -1 : 1; + if (va > vb) return ocol.direction === 'ASC' ? 1 : -1; + } + return 0; + }); + } + + // Compute values for each row in the partition + for (var k = 0; k < rowIndices.length; k++) { + var idx = rowIndices[k]; + var colId = wfConfig.expressionColumnId; + var value; + + switch (wfConfig.funcid) { + case 'LEAD': + var leadIdx = k + wfConfig.offset; + value = + leadIdx < rowIndices.length + ? res[rowIndices[leadIdx]][colId] + : wfConfig.defaultValue; + break; + case 'LAG': + var lagIdx = k - wfConfig.offset; + value = + lagIdx >= 0 + ? res[rowIndices[lagIdx]][colId] + : wfConfig.defaultValue; + break; + case 'FIRST_VALUE': + value = res[rowIndices[0]][colId]; + break; + case 'LAST_VALUE': + value = res[rowIndices[rowIndices.length - 1]][colId]; + break; + } + + res[idx][wfConfig.as] = value; + } + } + } + } + var res2 = modify(query, res); if (cb) { diff --git a/src/424select.js b/src/424select.js index 0d32d21ce0..792f510f85 100755 --- a/src/424select.js +++ b/src/424select.js @@ -570,6 +570,53 @@ yy.Select.prototype.compileSelectGroup0 = function (query) { if (col.funcid && col.funcid.toUpperCase() === 'GROUP_ROW_NUMBER') { query.grouprownums.push({as: col.as, columnIndex: 0}); // Track which column to use for grouping } + + // Detect positional window functions: LEAD, LAG, FIRST_VALUE, LAST_VALUE + if (col.funcid) { + var fid = col.funcid.toUpperCase(); + if ( + fid === 'LEAD' || + fid === 'LAG' || + fid === 'FIRST_VALUE' || + fid === 'LAST_VALUE' + ) { + var wfConfig = { + funcid: fid, + as: col.as, + expressionColumnId: + col.args && col.args[0] ? col.args[0].columnid : null, + offset: + col.args && col.args[1] ? col.args[1].value : 1, + defaultValue: + col.args && col.args[2] + ? col.args[2].value != null + ? col.args[2].value + : col.args[2].op === '-' && col.args[2].right + ? -col.args[2].right.value + : null + : null, + partitionColumns: + col.over && col.over.partition + ? col.over.partition.map(function (p) { + return p.columnid || p.toString(); + }) + : [], + orderColumns: + col.over && col.over.order + ? col.over.order.map(function (o) { + return { + columnid: + o.expression && o.expression.columnid + ? o.expression.columnid + : o.columnid || o.toString(), + direction: o.direction || 'ASC', + }; + }) + : [], + }; + query.windowfns.push(wfConfig); + } + } // console.log("colas:",colas); // } } else { diff --git a/src/55functions.js b/src/55functions.js index 9bdc42ff24..2343a7eded 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -250,6 +250,18 @@ stdlib.ROW_NUMBER = function () { stdlib.GROUP_ROW_NUMBER = function () { return '1'; }; +stdlib.LEAD = function () { + return 'undefined'; +}; +stdlib.LAG = function () { + return 'undefined'; +}; +stdlib.FIRST_VALUE = function () { + return 'undefined'; +}; +stdlib.LAST_VALUE = function () { + return 'undefined'; +}; stdlib.SQRT = function (s) { return 'Math.sqrt(' + s + ')'; diff --git a/test/test2409.js b/test/test2409.js new file mode 100644 index 0000000000..7ade965219 --- /dev/null +++ b/test/test2409.js @@ -0,0 +1,239 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +describe('Test 2409 - LEAD/LAG/FIRST_VALUE/LAST_VALUE Window Functions', function () { + var data = [ + {dept: 'Sales', emp: 'Alice', salary: 1000}, + {dept: 'Sales', emp: 'Bob', salary: 1200}, + {dept: 'Sales', emp: 'Carol', salary: 1500}, + {dept: 'IT', emp: 'Dave', salary: 2000}, + {dept: 'IT', emp: 'Eve', salary: 2500}, + ]; + + // --- LEAD tests --- + + it('1. LEAD basic - next row value', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', + [data] + ); + assert.strictEqual(res[0].next_salary, 1200); + assert.strictEqual(res[1].next_salary, 1500); + assert.strictEqual(res[4].next_salary, null); + }); + + it('2. LEAD with explicit offset', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary, 2) OVER (ORDER BY salary) AS next2_salary FROM ? ORDER BY salary', + [data] + ); + assert.strictEqual(res[0].next2_salary, 1500); + assert.strictEqual(res[1].next2_salary, 2000); + assert.strictEqual(res[3].next2_salary, null); + assert.strictEqual(res[4].next2_salary, null); + }); + + it('3. LEAD with custom default', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary, 1, 0) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', + [data] + ); + assert.strictEqual(res[4].next_salary, 0); + assert.strictEqual(res[0].next_salary, 1200); + }); + + it('4. LEAD with PARTITION BY', function () { + var res = alasql( + 'SELECT dept, emp, salary, LEAD(salary) OVER (PARTITION BY dept ORDER BY salary) AS next_salary FROM ? ORDER BY dept, salary', + [data] + ); + // IT partition: Dave(2000), Eve(2500) + var it = res.filter(function (r) { + return r.dept === 'IT'; + }); + assert.strictEqual(it[0].next_salary, 2500); + assert.strictEqual(it[1].next_salary, null); + + // Sales partition: Alice(1000), Bob(1200), Carol(1500) + var sales = res.filter(function (r) { + return r.dept === 'Sales'; + }); + assert.strictEqual(sales[0].next_salary, 1200); + assert.strictEqual(sales[1].next_salary, 1500); + assert.strictEqual(sales[2].next_salary, null); + }); + + // --- LAG tests --- + + it('5. LAG basic - previous row value', function () { + var res = alasql( + 'SELECT emp, salary, LAG(salary) OVER (ORDER BY salary) AS prev_salary FROM ? ORDER BY salary', + [data] + ); + assert.strictEqual(res[0].prev_salary, null); + assert.strictEqual(res[1].prev_salary, 1000); + assert.strictEqual(res[4].prev_salary, 2000); + }); + + it('6. LAG with offset and default', function () { + var res = alasql( + 'SELECT emp, salary, LAG(salary, 2, -1) OVER (ORDER BY salary) AS prev2_salary FROM ? ORDER BY salary', + [data] + ); + assert.strictEqual(res[0].prev2_salary, -1); + assert.strictEqual(res[1].prev2_salary, -1); + assert.strictEqual(res[2].prev2_salary, 1000); + assert.strictEqual(res[3].prev2_salary, 1200); + }); + + it('7. LAG with PARTITION BY', function () { + var res = alasql( + 'SELECT dept, emp, salary, LAG(salary) OVER (PARTITION BY dept ORDER BY salary) AS prev_salary FROM ? ORDER BY dept, salary', + [data] + ); + var it = res.filter(function (r) { + return r.dept === 'IT'; + }); + assert.strictEqual(it[0].prev_salary, null); + assert.strictEqual(it[1].prev_salary, 2000); + + var sales = res.filter(function (r) { + return r.dept === 'Sales'; + }); + assert.strictEqual(sales[0].prev_salary, null); + assert.strictEqual(sales[1].prev_salary, 1000); + assert.strictEqual(sales[2].prev_salary, 1200); + }); + + // --- FIRST_VALUE tests --- + + it('8. FIRST_VALUE basic', function () { + var res = alasql( + 'SELECT emp, salary, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal FROM ? ORDER BY salary', + [data] + ); + for (var i = 0; i < res.length; i++) { + assert.strictEqual(res[i].first_sal, 1000); + } + }); + + it('9. FIRST_VALUE with PARTITION BY', function () { + var res = alasql( + 'SELECT dept, emp, salary, FIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS first_sal FROM ? ORDER BY dept, salary', + [data] + ); + var it = res.filter(function (r) { + return r.dept === 'IT'; + }); + assert.strictEqual(it[0].first_sal, 2000); + assert.strictEqual(it[1].first_sal, 2000); + + var sales = res.filter(function (r) { + return r.dept === 'Sales'; + }); + assert.strictEqual(sales[0].first_sal, 1000); + assert.strictEqual(sales[2].first_sal, 1000); + }); + + it('10. FIRST_VALUE with column name reference', function () { + var res = alasql( + 'SELECT dept, emp, FIRST_VALUE(emp) OVER (PARTITION BY dept ORDER BY salary) AS first_emp FROM ? ORDER BY dept, salary', + [data] + ); + var it = res.filter(function (r) { + return r.dept === 'IT'; + }); + assert.strictEqual(it[0].first_emp, 'Dave'); + assert.strictEqual(it[1].first_emp, 'Dave'); + }); + + // --- LAST_VALUE tests --- + + it('11. LAST_VALUE basic', function () { + var res = alasql( + 'SELECT emp, salary, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', + [data] + ); + for (var i = 0; i < res.length; i++) { + assert.strictEqual(res[i].last_sal, 2500); + } + }); + + it('12. LAST_VALUE with PARTITION BY', function () { + var res = alasql( + 'SELECT dept, emp, salary, LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS last_sal FROM ? ORDER BY dept, salary', + [data] + ); + var it = res.filter(function (r) { + return r.dept === 'IT'; + }); + assert.strictEqual(it[0].last_sal, 2500); + assert.strictEqual(it[1].last_sal, 2500); + + var sales = res.filter(function (r) { + return r.dept === 'Sales'; + }); + assert.strictEqual(sales[0].last_sal, 1500); + assert.strictEqual(sales[2].last_sal, 1500); + }); + + // --- Edge cases --- + + it('13. Null values in column', function () { + var dataWithNulls = [ + {id: 1, val: 10}, + {id: 2, val: null}, + {id: 3, val: 30}, + ]; + var res = alasql( + 'SELECT id, val, LAG(val) OVER (ORDER BY id) AS prev_val FROM ? ORDER BY id', + [dataWithNulls] + ); + assert.strictEqual(res[0].prev_val, null); + assert.strictEqual(res[1].prev_val, 10); + assert.strictEqual(res[2].prev_val, null); // null from the data row + }); + + it('14. Offset exceeds partition size', function () { + var smallData = [ + {id: 1, val: 100}, + {id: 2, val: 200}, + ]; + var res = alasql( + 'SELECT id, val, LEAD(val, 5) OVER (ORDER BY id) AS far_ahead FROM ? ORDER BY id', + [smallData] + ); + assert.strictEqual(res[0].far_ahead, null); + assert.strictEqual(res[1].far_ahead, null); + }); + + it('15. Multiple window functions in one query', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_sal, LAG(salary) OVER (ORDER BY salary) AS prev_sal, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', + [data] + ); + // First row + assert.strictEqual(res[0].prev_sal, null); + assert.strictEqual(res[0].next_sal, 1200); + assert.strictEqual(res[0].first_sal, 1000); + assert.strictEqual(res[0].last_sal, 2500); + // Last row + assert.strictEqual(res[4].prev_sal, 2000); + assert.strictEqual(res[4].next_sal, null); + assert.strictEqual(res[4].first_sal, 1000); + assert.strictEqual(res[4].last_sal, 2500); + }); + + it('16. DESC ordering', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary DESC) AS next_sal FROM ? ORDER BY salary DESC', + [data] + ); + // DESC order: 2500, 2000, 1500, 1200, 1000 + assert.strictEqual(res[0].next_sal, 2000); + assert.strictEqual(res[1].next_sal, 1500); + assert.strictEqual(res[4].next_sal, null); + }); +}); From 653233189cf5b4458d2110896b308a0d8c0bb9d5 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Mon, 20 Apr 2026 22:04:42 +0530 Subject: [PATCH 2/3] Apply prettier formatting after develop merge --- src/40select.js | 5 +---- src/424select.js | 13 +++---------- test/test2409.js | 7 +++---- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/40select.js b/src/40select.js index 5b5e23f06e..fe95b0ca4c 100755 --- a/src/40select.js +++ b/src/40select.js @@ -568,10 +568,7 @@ yy.Select = class Select { break; case 'LAG': var lagIdx = k - wfConfig.offset; - value = - lagIdx >= 0 - ? res[rowIndices[lagIdx]][colId] - : wfConfig.defaultValue; + value = lagIdx >= 0 ? res[rowIndices[lagIdx]][colId] : wfConfig.defaultValue; break; case 'FIRST_VALUE': value = res[rowIndices[0]][colId]; diff --git a/src/424select.js b/src/424select.js index 792f510f85..c405fd7249 100755 --- a/src/424select.js +++ b/src/424select.js @@ -574,19 +574,12 @@ yy.Select.prototype.compileSelectGroup0 = function (query) { // Detect positional window functions: LEAD, LAG, FIRST_VALUE, LAST_VALUE if (col.funcid) { var fid = col.funcid.toUpperCase(); - if ( - fid === 'LEAD' || - fid === 'LAG' || - fid === 'FIRST_VALUE' || - fid === 'LAST_VALUE' - ) { + if (fid === 'LEAD' || fid === 'LAG' || fid === 'FIRST_VALUE' || fid === 'LAST_VALUE') { var wfConfig = { funcid: fid, as: col.as, - expressionColumnId: - col.args && col.args[0] ? col.args[0].columnid : null, - offset: - col.args && col.args[1] ? col.args[1].value : 1, + expressionColumnId: col.args && col.args[0] ? col.args[0].columnid : null, + offset: col.args && col.args[1] ? col.args[1].value : 1, defaultValue: col.args && col.args[2] ? col.args[2].value != null diff --git a/test/test2409.js b/test/test2409.js index 7ade965219..a2fe3ecd50 100644 --- a/test/test2409.js +++ b/test/test2409.js @@ -187,10 +187,9 @@ describe('Test 2409 - LEAD/LAG/FIRST_VALUE/LAST_VALUE Window Functions', functio {id: 2, val: null}, {id: 3, val: 30}, ]; - var res = alasql( - 'SELECT id, val, LAG(val) OVER (ORDER BY id) AS prev_val FROM ? ORDER BY id', - [dataWithNulls] - ); + var res = alasql('SELECT id, val, LAG(val) OVER (ORDER BY id) AS prev_val FROM ? ORDER BY id', [ + dataWithNulls, + ]); assert.strictEqual(res[0].prev_val, null); assert.strictEqual(res[1].prev_val, 10); assert.strictEqual(res[2].prev_val, null); // null from the data row From d39981d5cb4e66aebce803c6db8aebfcabfde388 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Tue, 21 Apr 2026 06:33:01 +0530 Subject: [PATCH 3/3] Move LEAD/LAG/FIRST_VALUE/LAST_VALUE detection into the parser Adds a typed yy.PositionalWindowFunc AST node produced by the FuncValue grammar rule so detection happens at parse time and can be cached/precompiled. Removes the runtime funcid string-matching from compileSelectGroup0 and the no-op stdlib stubs that only existed as flags for the runtime to detect. Restructures test/test2409.js into nested describes per function with deepStrictEqual assertions on full result objects. --- src/424select.js | 40 ---- src/47over.js | 54 ++++++ src/55functions.js | 12 -- src/alasqlparser.jison | 5 +- src/alasqlparser.js | 5 +- test/test2409.js | 427 ++++++++++++++++++++++------------------- 6 files changed, 293 insertions(+), 250 deletions(-) diff --git a/src/424select.js b/src/424select.js index c405fd7249..0d32d21ce0 100755 --- a/src/424select.js +++ b/src/424select.js @@ -570,46 +570,6 @@ yy.Select.prototype.compileSelectGroup0 = function (query) { if (col.funcid && col.funcid.toUpperCase() === 'GROUP_ROW_NUMBER') { query.grouprownums.push({as: col.as, columnIndex: 0}); // Track which column to use for grouping } - - // Detect positional window functions: LEAD, LAG, FIRST_VALUE, LAST_VALUE - if (col.funcid) { - var fid = col.funcid.toUpperCase(); - if (fid === 'LEAD' || fid === 'LAG' || fid === 'FIRST_VALUE' || fid === 'LAST_VALUE') { - var wfConfig = { - funcid: fid, - as: col.as, - expressionColumnId: col.args && col.args[0] ? col.args[0].columnid : null, - offset: col.args && col.args[1] ? col.args[1].value : 1, - defaultValue: - col.args && col.args[2] - ? col.args[2].value != null - ? col.args[2].value - : col.args[2].op === '-' && col.args[2].right - ? -col.args[2].right.value - : null - : null, - partitionColumns: - col.over && col.over.partition - ? col.over.partition.map(function (p) { - return p.columnid || p.toString(); - }) - : [], - orderColumns: - col.over && col.over.order - ? col.over.order.map(function (o) { - return { - columnid: - o.expression && o.expression.columnid - ? o.expression.columnid - : o.columnid || o.toString(), - direction: o.direction || 'ASC', - }; - }) - : [], - }; - query.windowfns.push(wfConfig); - } - } // console.log("colas:",colas); // } } else { diff --git a/src/47over.js b/src/47over.js index d4fb22e4cd..65a161aa71 100755 --- a/src/47over.js +++ b/src/47over.js @@ -24,3 +24,57 @@ yy.Over = class Over { return s; } }; + +yy.PositionalWindowFunc = class PositionalWindowFunc { + constructor(params) { + Object.assign(this, params); + } + + toString() { + let s = this.funcid + '('; + if (this.args && this.args.length) { + s += this.args.map(a => a.toString()).join(','); + } + s += ')'; + if (this.over) s += ' ' + this.over.toString(); + return s; + } + + findAggregator(query) { + const defaultArg = this.args && this.args[2]; + let defaultValue = null; + if (defaultArg) { + if (defaultArg.value != null) { + defaultValue = defaultArg.value; + } else if (defaultArg.op === '-' && defaultArg.right) { + defaultValue = -defaultArg.right.value; + } + } + + query.windowfns.push({ + funcid: this.funcid, + as: this.as, + expressionColumnId: this.args && this.args[0] ? this.args[0].columnid : null, + offset: this.args && this.args[1] ? this.args[1].value : 1, + defaultValue: defaultValue, + partitionColumns: + this.over && this.over.partition + ? this.over.partition.map(p => p.columnid || p.toString()) + : [], + orderColumns: + this.over && this.over.order + ? this.over.order.map(o => ({ + columnid: + o.expression && o.expression.columnid + ? o.expression.columnid + : o.columnid || o.toString(), + direction: o.direction || 'ASC', + })) + : [], + }); + } + + toJS() { + return 'undefined'; + } +}; diff --git a/src/55functions.js b/src/55functions.js index 2343a7eded..9bdc42ff24 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -250,18 +250,6 @@ stdlib.ROW_NUMBER = function () { stdlib.GROUP_ROW_NUMBER = function () { return '1'; }; -stdlib.LEAD = function () { - return 'undefined'; -}; -stdlib.LAG = function () { - return 'undefined'; -}; -stdlib.FIRST_VALUE = function () { - return 'undefined'; -}; -stdlib.LAST_VALUE = function () { - return 'undefined'; -}; stdlib.SQRT = function (s) { return 'Math.sqrt(' + s + ')'; diff --git a/src/alasqlparser.jison b/src/alasqlparser.jison index a0b4e895b6..7033021a22 100755 --- a/src/alasqlparser.jison +++ b/src/alasqlparser.jison @@ -1544,8 +1544,11 @@ FuncValue { var funcid = $1; var exprlist = $4; - if(exprlist.length > 1 && (funcid.toUpperCase() == 'MIN' || funcid.toUpperCase() == 'MAX')) { + var fidU = funcid.toUpperCase(); + if(exprlist.length > 1 && (fidU == 'MIN' || fidU == 'MAX')) { $$ = new yy.FuncValue({funcid: funcid, args: exprlist, over: $6}); + } else if(fidU == 'LEAD' || fidU == 'LAG' || fidU == 'FIRST_VALUE' || fidU == 'LAST_VALUE') { + $$ = new yy.PositionalWindowFunc({funcid: fidU, args: exprlist, over: $6}); } else if(alasql.aggr[$1]) { $$ = new yy.AggrValue({aggregatorid: 'REDUCE', funcid: funcid, expression: exprlist[0], args: exprlist, distinct:($3=='DISTINCT'), over: $6 }); diff --git a/src/alasqlparser.js b/src/alasqlparser.js index 62071b9f8b..d0d3624c06 100755 --- a/src/alasqlparser.js +++ b/src/alasqlparser.js @@ -945,8 +945,11 @@ case 367: var funcid = $$[$0-5]; var exprlist = $$[$0-2]; - if(exprlist.length > 1 && (funcid.toUpperCase() == 'MIN' || funcid.toUpperCase() == 'MAX')) { + var fidU = funcid.toUpperCase(); + if(exprlist.length > 1 && (fidU == 'MIN' || fidU == 'MAX')) { this.$ = new yy.FuncValue({funcid: funcid, args: exprlist, over: $$[$0]}); + } else if(fidU == 'LEAD' || fidU == 'LAG' || fidU == 'FIRST_VALUE' || fidU == 'LAST_VALUE') { + this.$ = new yy.PositionalWindowFunc({funcid: fidU, args: exprlist, over: $$[$0]}); } else if(alasql.aggr[$$[$0-5]]) { this.$ = new yy.AggrValue({aggregatorid: 'REDUCE', funcid: funcid, expression: exprlist[0], args: exprlist, distinct:($$[$0-3]=='DISTINCT'), over: $$[$0] }); diff --git a/test/test2409.js b/test/test2409.js index a2fe3ecd50..093fd537cf 100644 --- a/test/test2409.js +++ b/test/test2409.js @@ -12,227 +12,262 @@ describe('Test 2409 - LEAD/LAG/FIRST_VALUE/LAST_VALUE Window Functions', functio {dept: 'IT', emp: 'Eve', salary: 2500}, ]; - // --- LEAD tests --- - - it('1. LEAD basic - next row value', function () { - var res = alasql( - 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', - [data] - ); - assert.strictEqual(res[0].next_salary, 1200); - assert.strictEqual(res[1].next_salary, 1500); - assert.strictEqual(res[4].next_salary, null); - }); - - it('2. LEAD with explicit offset', function () { - var res = alasql( - 'SELECT emp, salary, LEAD(salary, 2) OVER (ORDER BY salary) AS next2_salary FROM ? ORDER BY salary', - [data] - ); - assert.strictEqual(res[0].next2_salary, 1500); - assert.strictEqual(res[1].next2_salary, 2000); - assert.strictEqual(res[3].next2_salary, null); - assert.strictEqual(res[4].next2_salary, null); - }); - - it('3. LEAD with custom default', function () { - var res = alasql( - 'SELECT emp, salary, LEAD(salary, 1, 0) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', - [data] - ); - assert.strictEqual(res[4].next_salary, 0); - assert.strictEqual(res[0].next_salary, 1200); - }); - - it('4. LEAD with PARTITION BY', function () { - var res = alasql( - 'SELECT dept, emp, salary, LEAD(salary) OVER (PARTITION BY dept ORDER BY salary) AS next_salary FROM ? ORDER BY dept, salary', - [data] - ); - // IT partition: Dave(2000), Eve(2500) - var it = res.filter(function (r) { - return r.dept === 'IT'; - }); - assert.strictEqual(it[0].next_salary, 2500); - assert.strictEqual(it[1].next_salary, null); - - // Sales partition: Alice(1000), Bob(1200), Carol(1500) - var sales = res.filter(function (r) { - return r.dept === 'Sales'; - }); - assert.strictEqual(sales[0].next_salary, 1200); - assert.strictEqual(sales[1].next_salary, 1500); - assert.strictEqual(sales[2].next_salary, null); - }); - - // --- LAG tests --- - - it('5. LAG basic - previous row value', function () { - var res = alasql( - 'SELECT emp, salary, LAG(salary) OVER (ORDER BY salary) AS prev_salary FROM ? ORDER BY salary', - [data] - ); - assert.strictEqual(res[0].prev_salary, null); - assert.strictEqual(res[1].prev_salary, 1000); - assert.strictEqual(res[4].prev_salary, 2000); - }); + describe('LEAD', function () { + it('returns the next row value with default offset', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, next_salary: 1200}, + {emp: 'Bob', salary: 1200, next_salary: 1500}, + {emp: 'Carol', salary: 1500, next_salary: 2000}, + {emp: 'Dave', salary: 2000, next_salary: 2500}, + {emp: 'Eve', salary: 2500, next_salary: null}, + ]); + }); - it('6. LAG with offset and default', function () { - var res = alasql( - 'SELECT emp, salary, LAG(salary, 2, -1) OVER (ORDER BY salary) AS prev2_salary FROM ? ORDER BY salary', - [data] - ); - assert.strictEqual(res[0].prev2_salary, -1); - assert.strictEqual(res[1].prev2_salary, -1); - assert.strictEqual(res[2].prev2_salary, 1000); - assert.strictEqual(res[3].prev2_salary, 1200); - }); + it('honours an explicit offset', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary, 2) OVER (ORDER BY salary) AS next2_salary FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, next2_salary: 1500}, + {emp: 'Bob', salary: 1200, next2_salary: 2000}, + {emp: 'Carol', salary: 1500, next2_salary: 2500}, + {emp: 'Dave', salary: 2000, next2_salary: null}, + {emp: 'Eve', salary: 2500, next2_salary: null}, + ]); + }); - it('7. LAG with PARTITION BY', function () { - var res = alasql( - 'SELECT dept, emp, salary, LAG(salary) OVER (PARTITION BY dept ORDER BY salary) AS prev_salary FROM ? ORDER BY dept, salary', - [data] - ); - var it = res.filter(function (r) { - return r.dept === 'IT'; + it('uses a custom default when no row is ahead', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary, 1, 0) OVER (ORDER BY salary) AS next_salary FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, next_salary: 1200}, + {emp: 'Bob', salary: 1200, next_salary: 1500}, + {emp: 'Carol', salary: 1500, next_salary: 2000}, + {emp: 'Dave', salary: 2000, next_salary: 2500}, + {emp: 'Eve', salary: 2500, next_salary: 0}, + ]); }); - assert.strictEqual(it[0].prev_salary, null); - assert.strictEqual(it[1].prev_salary, 2000); - var sales = res.filter(function (r) { - return r.dept === 'Sales'; + it('restarts within each PARTITION BY group', function () { + var res = alasql( + 'SELECT dept, emp, salary, LEAD(salary) OVER (PARTITION BY dept ORDER BY salary) AS next_salary FROM ? ORDER BY dept, salary', + [data] + ); + assert.deepStrictEqual(res, [ + {dept: 'IT', emp: 'Dave', salary: 2000, next_salary: 2500}, + {dept: 'IT', emp: 'Eve', salary: 2500, next_salary: null}, + {dept: 'Sales', emp: 'Alice', salary: 1000, next_salary: 1200}, + {dept: 'Sales', emp: 'Bob', salary: 1200, next_salary: 1500}, + {dept: 'Sales', emp: 'Carol', salary: 1500, next_salary: null}, + ]); }); - assert.strictEqual(sales[0].prev_salary, null); - assert.strictEqual(sales[1].prev_salary, 1000); - assert.strictEqual(sales[2].prev_salary, 1200); - }); - // --- FIRST_VALUE tests --- + it('walks DESC ordering correctly', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary DESC) AS next_sal FROM ? ORDER BY salary DESC', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Eve', salary: 2500, next_sal: 2000}, + {emp: 'Dave', salary: 2000, next_sal: 1500}, + {emp: 'Carol', salary: 1500, next_sal: 1200}, + {emp: 'Bob', salary: 1200, next_sal: 1000}, + {emp: 'Alice', salary: 1000, next_sal: null}, + ]); + }); - it('8. FIRST_VALUE basic', function () { - var res = alasql( - 'SELECT emp, salary, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal FROM ? ORDER BY salary', - [data] - ); - for (var i = 0; i < res.length; i++) { - assert.strictEqual(res[i].first_sal, 1000); - } + it('returns the default when offset exceeds partition size', function () { + var smallData = [ + {id: 1, val: 100}, + {id: 2, val: 200}, + ]; + var res = alasql( + 'SELECT id, val, LEAD(val, 5) OVER (ORDER BY id) AS far_ahead FROM ? ORDER BY id', + [smallData] + ); + assert.deepStrictEqual(res, [ + {id: 1, val: 100, far_ahead: null}, + {id: 2, val: 200, far_ahead: null}, + ]); + }); }); - it('9. FIRST_VALUE with PARTITION BY', function () { - var res = alasql( - 'SELECT dept, emp, salary, FIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS first_sal FROM ? ORDER BY dept, salary', - [data] - ); - var it = res.filter(function (r) { - return r.dept === 'IT'; + describe('LAG', function () { + it('returns the previous row value with default offset', function () { + var res = alasql( + 'SELECT emp, salary, LAG(salary) OVER (ORDER BY salary) AS prev_salary FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, prev_salary: null}, + {emp: 'Bob', salary: 1200, prev_salary: 1000}, + {emp: 'Carol', salary: 1500, prev_salary: 1200}, + {emp: 'Dave', salary: 2000, prev_salary: 1500}, + {emp: 'Eve', salary: 2500, prev_salary: 2000}, + ]); }); - assert.strictEqual(it[0].first_sal, 2000); - assert.strictEqual(it[1].first_sal, 2000); - var sales = res.filter(function (r) { - return r.dept === 'Sales'; + it('honours offset and default value (including negatives)', function () { + var res = alasql( + 'SELECT emp, salary, LAG(salary, 2, -1) OVER (ORDER BY salary) AS prev2_salary FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, prev2_salary: -1}, + {emp: 'Bob', salary: 1200, prev2_salary: -1}, + {emp: 'Carol', salary: 1500, prev2_salary: 1000}, + {emp: 'Dave', salary: 2000, prev2_salary: 1200}, + {emp: 'Eve', salary: 2500, prev2_salary: 1500}, + ]); }); - assert.strictEqual(sales[0].first_sal, 1000); - assert.strictEqual(sales[2].first_sal, 1000); - }); - it('10. FIRST_VALUE with column name reference', function () { - var res = alasql( - 'SELECT dept, emp, FIRST_VALUE(emp) OVER (PARTITION BY dept ORDER BY salary) AS first_emp FROM ? ORDER BY dept, salary', - [data] - ); - var it = res.filter(function (r) { - return r.dept === 'IT'; + it('restarts within each PARTITION BY group', function () { + var res = alasql( + 'SELECT dept, emp, salary, LAG(salary) OVER (PARTITION BY dept ORDER BY salary) AS prev_salary FROM ? ORDER BY dept, salary', + [data] + ); + assert.deepStrictEqual(res, [ + {dept: 'IT', emp: 'Dave', salary: 2000, prev_salary: null}, + {dept: 'IT', emp: 'Eve', salary: 2500, prev_salary: 2000}, + {dept: 'Sales', emp: 'Alice', salary: 1000, prev_salary: null}, + {dept: 'Sales', emp: 'Bob', salary: 1200, prev_salary: 1000}, + {dept: 'Sales', emp: 'Carol', salary: 1500, prev_salary: 1200}, + ]); }); - assert.strictEqual(it[0].first_emp, 'Dave'); - assert.strictEqual(it[1].first_emp, 'Dave'); - }); - - // --- LAST_VALUE tests --- - it('11. LAST_VALUE basic', function () { - var res = alasql( - 'SELECT emp, salary, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', - [data] - ); - for (var i = 0; i < res.length; i++) { - assert.strictEqual(res[i].last_sal, 2500); - } + it('returns null when the source value itself is null', function () { + var dataWithNulls = [ + {id: 1, val: 10}, + {id: 2, val: null}, + {id: 3, val: 30}, + ]; + var res = alasql( + 'SELECT id, val, LAG(val) OVER (ORDER BY id) AS prev_val FROM ? ORDER BY id', + [dataWithNulls] + ); + assert.deepStrictEqual(res, [ + {id: 1, val: 10, prev_val: null}, + {id: 2, val: null, prev_val: 10}, + {id: 3, val: 30, prev_val: null}, + ]); + }); }); - it('12. LAST_VALUE with PARTITION BY', function () { - var res = alasql( - 'SELECT dept, emp, salary, LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS last_sal FROM ? ORDER BY dept, salary', - [data] - ); - var it = res.filter(function (r) { - return r.dept === 'IT'; + describe('FIRST_VALUE', function () { + it('returns the partition-wide minimum-by-order value', function () { + var res = alasql( + 'SELECT emp, salary, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, first_sal: 1000}, + {emp: 'Bob', salary: 1200, first_sal: 1000}, + {emp: 'Carol', salary: 1500, first_sal: 1000}, + {emp: 'Dave', salary: 2000, first_sal: 1000}, + {emp: 'Eve', salary: 2500, first_sal: 1000}, + ]); }); - assert.strictEqual(it[0].last_sal, 2500); - assert.strictEqual(it[1].last_sal, 2500); - var sales = res.filter(function (r) { - return r.dept === 'Sales'; + it('restarts within each PARTITION BY group', function () { + var res = alasql( + 'SELECT dept, emp, salary, FIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS first_sal FROM ? ORDER BY dept, salary', + [data] + ); + assert.deepStrictEqual(res, [ + {dept: 'IT', emp: 'Dave', salary: 2000, first_sal: 2000}, + {dept: 'IT', emp: 'Eve', salary: 2500, first_sal: 2000}, + {dept: 'Sales', emp: 'Alice', salary: 1000, first_sal: 1000}, + {dept: 'Sales', emp: 'Bob', salary: 1200, first_sal: 1000}, + {dept: 'Sales', emp: 'Carol', salary: 1500, first_sal: 1000}, + ]); }); - assert.strictEqual(sales[0].last_sal, 1500); - assert.strictEqual(sales[2].last_sal, 1500); - }); - // --- Edge cases --- - - it('13. Null values in column', function () { - var dataWithNulls = [ - {id: 1, val: 10}, - {id: 2, val: null}, - {id: 3, val: 30}, - ]; - var res = alasql('SELECT id, val, LAG(val) OVER (ORDER BY id) AS prev_val FROM ? ORDER BY id', [ - dataWithNulls, - ]); - assert.strictEqual(res[0].prev_val, null); - assert.strictEqual(res[1].prev_val, 10); - assert.strictEqual(res[2].prev_val, null); // null from the data row + it('works on non-numeric columns', function () { + var res = alasql( + 'SELECT dept, emp, FIRST_VALUE(emp) OVER (PARTITION BY dept ORDER BY salary) AS first_emp FROM ? ORDER BY dept, salary', + [data] + ); + assert.deepStrictEqual(res, [ + {dept: 'IT', emp: 'Dave', first_emp: 'Dave'}, + {dept: 'IT', emp: 'Eve', first_emp: 'Dave'}, + {dept: 'Sales', emp: 'Alice', first_emp: 'Alice'}, + {dept: 'Sales', emp: 'Bob', first_emp: 'Alice'}, + {dept: 'Sales', emp: 'Carol', first_emp: 'Alice'}, + ]); + }); }); - it('14. Offset exceeds partition size', function () { - var smallData = [ - {id: 1, val: 100}, - {id: 2, val: 200}, - ]; - var res = alasql( - 'SELECT id, val, LEAD(val, 5) OVER (ORDER BY id) AS far_ahead FROM ? ORDER BY id', - [smallData] - ); - assert.strictEqual(res[0].far_ahead, null); - assert.strictEqual(res[1].far_ahead, null); - }); + describe('LAST_VALUE', function () { + it('returns the partition-wide maximum-by-order value', function () { + var res = alasql( + 'SELECT emp, salary, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + {emp: 'Alice', salary: 1000, last_sal: 2500}, + {emp: 'Bob', salary: 1200, last_sal: 2500}, + {emp: 'Carol', salary: 1500, last_sal: 2500}, + {emp: 'Dave', salary: 2000, last_sal: 2500}, + {emp: 'Eve', salary: 2500, last_sal: 2500}, + ]); + }); - it('15. Multiple window functions in one query', function () { - var res = alasql( - 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_sal, LAG(salary) OVER (ORDER BY salary) AS prev_sal, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', - [data] - ); - // First row - assert.strictEqual(res[0].prev_sal, null); - assert.strictEqual(res[0].next_sal, 1200); - assert.strictEqual(res[0].first_sal, 1000); - assert.strictEqual(res[0].last_sal, 2500); - // Last row - assert.strictEqual(res[4].prev_sal, 2000); - assert.strictEqual(res[4].next_sal, null); - assert.strictEqual(res[4].first_sal, 1000); - assert.strictEqual(res[4].last_sal, 2500); + it('restarts within each PARTITION BY group', function () { + var res = alasql( + 'SELECT dept, emp, salary, LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) AS last_sal FROM ? ORDER BY dept, salary', + [data] + ); + assert.deepStrictEqual(res, [ + {dept: 'IT', emp: 'Dave', salary: 2000, last_sal: 2500}, + {dept: 'IT', emp: 'Eve', salary: 2500, last_sal: 2500}, + {dept: 'Sales', emp: 'Alice', salary: 1000, last_sal: 1500}, + {dept: 'Sales', emp: 'Bob', salary: 1200, last_sal: 1500}, + {dept: 'Sales', emp: 'Carol', salary: 1500, last_sal: 1500}, + ]); + }); }); - it('16. DESC ordering', function () { - var res = alasql( - 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary DESC) AS next_sal FROM ? ORDER BY salary DESC', - [data] - ); - // DESC order: 2500, 2000, 1500, 1200, 1000 - assert.strictEqual(res[0].next_sal, 2000); - assert.strictEqual(res[1].next_sal, 1500); - assert.strictEqual(res[4].next_sal, null); + describe('Combined window functions', function () { + it('evaluates LEAD/LAG/FIRST_VALUE/LAST_VALUE in a single query', function () { + var res = alasql( + 'SELECT emp, salary, LEAD(salary) OVER (ORDER BY salary) AS next_sal, LAG(salary) OVER (ORDER BY salary) AS prev_sal, FIRST_VALUE(salary) OVER (ORDER BY salary) AS first_sal, LAST_VALUE(salary) OVER (ORDER BY salary) AS last_sal FROM ? ORDER BY salary', + [data] + ); + assert.deepStrictEqual(res, [ + { + emp: 'Alice', + salary: 1000, + next_sal: 1200, + prev_sal: null, + first_sal: 1000, + last_sal: 2500, + }, + {emp: 'Bob', salary: 1200, next_sal: 1500, prev_sal: 1000, first_sal: 1000, last_sal: 2500}, + { + emp: 'Carol', + salary: 1500, + next_sal: 2000, + prev_sal: 1200, + first_sal: 1000, + last_sal: 2500, + }, + { + emp: 'Dave', + salary: 2000, + next_sal: 2500, + prev_sal: 1500, + first_sal: 1000, + last_sal: 2500, + }, + {emp: 'Eve', salary: 2500, next_sal: null, prev_sal: 2000, first_sal: 1000, last_sal: 2500}, + ]); + }); }); });