From be8baf680531d46bfe3b04a741c42295e4ec9eb2 Mon Sep 17 00:00:00 2001 From: abhirupone Date: Fri, 31 Jul 2026 22:05:28 +0530 Subject: [PATCH 1/3] feat: add MODE aggregate function (#2482) --- src/55functions.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ test/test999_mode.js | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 test/test999_mode.js diff --git a/src/55functions.js b/src/55functions.js index 9bdc42ff24..9129ef6f52 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -389,6 +389,57 @@ alasql.aggr.median = alasql.aggr.MEDIAN = function (v, s, stage) { } }; +alasql.aggr.mode = alasql.aggr.MODE = function (v, s, stage) { + if (stage === 2) { + if (v !== undefined && v !== null) { + s.push(v); + } + return s; + } + + if (stage === 1) { + if (v === undefined || v === null) { + return []; + } + return [v]; + } + + if (!s.length) { + return undefined; + } + + let counts = new Map(); + let maxCount = 0; + + for (let i = 0; i < s.length; i++) { + let val = s[i]; + let count = (counts.get(val) || 0) + 1; + counts.set(val, count); + if (count > maxCount) { + maxCount = count; + } + } + + let candidates = []; + for (let [val, count] of counts.entries()) { + if (count === maxCount) { + candidates.push(val); + } + } + + if (candidates.length === 1) { + return candidates[0]; + } + + candidates.sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }); + + return candidates[0]; +}; + alasql.aggr.QUART = function (v, s, stage, nth) { //Quartile (first quartile per default or input param) if (stage === 2) { diff --git a/test/test999_mode.js b/test/test999_mode.js new file mode 100644 index 0000000000..d3a4eac390 --- /dev/null +++ b/test/test999_mode.js @@ -0,0 +1,49 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +describe('Test MODE aggregate function', function () { + it('1. Basic numbers mode', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: 2}, {a: 2}, {a: 3}]]); + assert.deepStrictEqual(res, [{m: 2}]); + }); + + it('2. Mode with tie breaking (smallest value)', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 3}, {a: 1}, {a: 3}, {a: 1}]]); + assert.deepStrictEqual(res, [{m: 1}]); + }); + + it('3. Mode with strings', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [ + [{a: 'apple'}, {a: 'banana'}, {a: 'apple'}, {a: 'orange'}], + ]); + assert.deepStrictEqual(res, [{m: 'apple'}]); + }); + + it('4. Mode ignores NULL and undefined values', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: 5}, {a: undefined}, {a: 5}]]); + assert.deepStrictEqual(res, [{m: 5}]); + }); + + it('5. Mode with GROUP BY', function () { + var data = [ + {g: 1, v: 10}, + {g: 1, v: 20}, + {g: 1, v: 20}, + {g: 2, v: 30}, + {g: 2, v: 30}, + {g: 2, v: 40}, + ]; + var res = alasql('SELECT g, MODE(v) AS m FROM ? GROUP BY g ORDER BY g', [data]); + assert.deepStrictEqual(res, [ + {g: 1, m: 20}, + {g: 2, m: 30}, + ]); + }); + + it('6. Empty input returns undefined/null', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[]]); + assert.deepStrictEqual(res, [{m: undefined}]); + }); +}); From 001da4c930bbc03d511ad3558dbe5539cfe4354b Mon Sep 17 00:00:00 2001 From: abhirupone Date: Tue, 18 Aug 2026 11:38:55 +0530 Subject: [PATCH 2/3] test: rename to test2482.js and add edge case tests for MODE (#2482) --- test/test2482.js | 111 +++++++++++++++++++++++++++++++++++++++++++ test/test999_mode.js | 49 ------------------- 2 files changed, 111 insertions(+), 49 deletions(-) create mode 100644 test/test2482.js delete mode 100644 test/test999_mode.js diff --git a/test/test2482.js b/test/test2482.js new file mode 100644 index 0000000000..44a54efcf0 --- /dev/null +++ b/test/test2482.js @@ -0,0 +1,111 @@ +if (typeof exports === 'object') { + var assert = require('assert'); + var alasql = require('..'); +} + +describe('Test 2482 - MODE aggregate function', function () { + it('1. Basic numbers mode', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: 2}, {a: 2}, {a: 3}]]); + assert.deepStrictEqual(res, [{m: 2}]); + }); + + it('2. Mode with tie breaking (smallest value)', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 3}, {a: 1}, {a: 3}, {a: 1}]]); + assert.deepStrictEqual(res, [{m: 1}]); + }); + + it('3. Mode with strings', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [ + [{a: 'apple'}, {a: 'banana'}, {a: 'apple'}, {a: 'orange'}], + ]); + assert.deepStrictEqual(res, [{m: 'apple'}]); + }); + + it('4. Mode ignores NULL and undefined values when other values exist', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: 5}, {a: undefined}, {a: 5}]]); + assert.deepStrictEqual(res, [{m: 5}]); + }); + + it('5. All NULLs or undefined', function () { + var resNull = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: null}]]); + assert.deepStrictEqual(resNull, [{m: undefined}]); + + var resUndefined = alasql('SELECT MODE(a) AS m FROM ?', [[{a: undefined}, {a: undefined}]]); + assert.deepStrictEqual(resUndefined, [{m: undefined}]); + + var resMixed = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: undefined}]]); + assert.deepStrictEqual(resMixed, [{m: undefined}]); + }); + + it('6. Boolean values', function () { + var resTrue = alasql('SELECT MODE(a) AS m FROM ?', [[{a: true}, {a: false}, {a: true}]]); + assert.deepStrictEqual(resTrue, [{m: true}]); + + var resFalse = alasql('SELECT MODE(a) AS m FROM ?', [[{a: false}, {a: true}, {a: false}]]); + assert.deepStrictEqual(resFalse, [{m: false}]); + + // Tie breaking between booleans: false < true + var resTie = alasql('SELECT MODE(a) AS m FROM ?', [[{a: true}, {a: false}]]); + assert.deepStrictEqual(resTie, [{m: false}]); + }); + + it('7. Strict Equality / Mixed Types', function () { + // 1 and '1' are treated as distinct types + var resNum = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: '1'}, {a: 1}]]); + assert.deepStrictEqual(resNum, [{m: 1}]); + + var resStr = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: '1'}, {a: '1'}]]); + assert.deepStrictEqual(resStr, [{m: '1'}]); + + // 0 vs false + var resZero = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 0}, {a: false}, {a: 0}]]); + assert.deepStrictEqual(resZero, [{m: 0}]); + + // true vs 'true' + var resBoolStr = alasql('SELECT MODE(a) AS m FROM ?', [[{a: true}, {a: 'true'}, {a: true}]]); + assert.deepStrictEqual(resBoolStr, [{m: true}]); + }); + + it('8. Negative Numbers and Floats', function () { + var resNeg = alasql('SELECT MODE(a) AS m FROM ?', [[{a: -10}, {a: -20}, {a: -10}, {a: -30}]]); + assert.deepStrictEqual(resNeg, [{m: -10}]); + + var resFloat = alasql('SELECT MODE(a) AS m FROM ?', [ + [{a: 1.5}, {a: 2.25}, {a: 1.5}, {a: 3.75}], + ]); + assert.deepStrictEqual(resFloat, [{m: 1.5}]); + + var resNegFloat = alasql('SELECT MODE(a) AS m FROM ?', [ + [{a: -3.14}, {a: 2.71}, {a: -3.14}, {a: -0.5}], + ]); + assert.deepStrictEqual(resNegFloat, [{m: -3.14}]); + + // Negative number tie breaking: -10 < -5 + var resNegTie = alasql('SELECT MODE(a) AS m FROM ?', [[{a: -5}, {a: -10}, {a: -5}, {a: -10}]]); + assert.deepStrictEqual(resNegTie, [{m: -10}]); + }); + + it('9. Mode with GROUP BY', function () { + var data = [ + {g: 1, v: 10}, + {g: 1, v: 20}, + {g: 1, v: 20}, + {g: 2, v: 30}, + {g: 2, v: 30}, + {g: 2, v: 40}, + {g: 3, v: null}, + {g: 3, v: undefined}, + ]; + var res = alasql('SELECT g, MODE(v) AS m FROM ? GROUP BY g ORDER BY g', [data]); + assert.deepStrictEqual(res, [ + {g: 1, m: 20}, + {g: 2, m: 30}, + {g: 3, m: undefined}, + ]); + }); + + it('10. Empty input returns undefined/null', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[]]); + assert.deepStrictEqual(res, [{m: undefined}]); + }); +}); diff --git a/test/test999_mode.js b/test/test999_mode.js deleted file mode 100644 index d3a4eac390..0000000000 --- a/test/test999_mode.js +++ /dev/null @@ -1,49 +0,0 @@ -if (typeof exports === 'object') { - var assert = require('assert'); - var alasql = require('..'); -} - -describe('Test MODE aggregate function', function () { - it('1. Basic numbers mode', function () { - var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: 2}, {a: 2}, {a: 3}]]); - assert.deepStrictEqual(res, [{m: 2}]); - }); - - it('2. Mode with tie breaking (smallest value)', function () { - var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 3}, {a: 1}, {a: 3}, {a: 1}]]); - assert.deepStrictEqual(res, [{m: 1}]); - }); - - it('3. Mode with strings', function () { - var res = alasql('SELECT MODE(a) AS m FROM ?', [ - [{a: 'apple'}, {a: 'banana'}, {a: 'apple'}, {a: 'orange'}], - ]); - assert.deepStrictEqual(res, [{m: 'apple'}]); - }); - - it('4. Mode ignores NULL and undefined values', function () { - var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: 5}, {a: undefined}, {a: 5}]]); - assert.deepStrictEqual(res, [{m: 5}]); - }); - - it('5. Mode with GROUP BY', function () { - var data = [ - {g: 1, v: 10}, - {g: 1, v: 20}, - {g: 1, v: 20}, - {g: 2, v: 30}, - {g: 2, v: 30}, - {g: 2, v: 40}, - ]; - var res = alasql('SELECT g, MODE(v) AS m FROM ? GROUP BY g ORDER BY g', [data]); - assert.deepStrictEqual(res, [ - {g: 1, m: 20}, - {g: 2, m: 30}, - ]); - }); - - it('6. Empty input returns undefined/null', function () { - var res = alasql('SELECT MODE(a) AS m FROM ?', [[]]); - assert.deepStrictEqual(res, [{m: undefined}]); - }); -}); From b5d339852dff72e64c12fe54e68dc4fb2afe08ea Mon Sep 17 00:00:00 2001 From: abhirupone Date: Wed, 19 Aug 2026 18:13:24 +0530 Subject: [PATCH 3/3] refactor(aggr): count incrementally in Map, explicit stage 3, and add tests for MODE (#2482) --- src/55functions.js | 68 +++++++++++++++++++-------------------- test/test2482.js | 80 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 45 deletions(-) diff --git a/src/55functions.js b/src/55functions.js index 9129ef6f52..5a11ee5fd3 100644 --- a/src/55functions.js +++ b/src/55functions.js @@ -390,54 +390,50 @@ alasql.aggr.median = alasql.aggr.MEDIAN = function (v, s, stage) { }; alasql.aggr.mode = alasql.aggr.MODE = function (v, s, stage) { - if (stage === 2) { - if (v !== undefined && v !== null) { - s.push(v); - } - return s; - } - if (stage === 1) { - if (v === undefined || v === null) { - return []; + if (v == null) { + return {counts: new Map(), maxCount: 0}; } - return [v]; + return {counts: new Map([[v, 1]]), maxCount: 1}; } - if (!s.length) { - return undefined; + if (stage === 2) { + if (!s || !s.counts) { + s = {counts: new Map(), maxCount: 0}; + } + if (v == null) { + return s; + } + const count = (s.counts.get(v) || 0) + 1; + s.counts.set(v, count); + if (count > s.maxCount) { + s.maxCount = count; + } + return s; } - let counts = new Map(); - let maxCount = 0; - - for (let i = 0; i < s.length; i++) { - let val = s[i]; - let count = (counts.get(val) || 0) + 1; - counts.set(val, count); - if (count > maxCount) { - maxCount = count; + if (stage === 3) { + if (!s || s.maxCount === 0) { + return undefined; } - } - let candidates = []; - for (let [val, count] of counts.entries()) { - if (count === maxCount) { - candidates.push(val); + let result; + let hasResult = false; + + for (const [value, count] of s.counts) { + if (count !== s.maxCount) { + continue; + } + if (!hasResult || value < result) { + result = value; + hasResult = true; + } } - } - if (candidates.length === 1) { - return candidates[0]; + return result; } - candidates.sort((a, b) => { - if (a < b) return -1; - if (a > b) return 1; - return 0; - }); - - return candidates[0]; + return undefined; }; alasql.aggr.QUART = function (v, s, stage, nth) { diff --git a/test/test2482.js b/test/test2482.js index 44a54efcf0..f3c9dd842b 100644 --- a/test/test2482.js +++ b/test/test2482.js @@ -9,24 +9,39 @@ describe('Test 2482 - MODE aggregate function', function () { assert.deepStrictEqual(res, [{m: 2}]); }); - it('2. Mode with tie breaking (smallest value)', function () { + it('2. Single value', function () { + var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 42}]]); + assert.deepStrictEqual(res, [{m: 42}]); + }); + + it('3. Mode with tie breaking (smallest value)', function () { var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 3}, {a: 1}, {a: 3}, {a: 1}]]); assert.deepStrictEqual(res, [{m: 1}]); + + var resOrder = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 2}, {a: 1}, {a: 1}, {a: 2}]]); + assert.deepStrictEqual(resOrder, [{m: 1}]); }); - it('3. Mode with strings', function () { + it('4. Mode with strings and string ties', function () { var res = alasql('SELECT MODE(a) AS m FROM ?', [ [{a: 'apple'}, {a: 'banana'}, {a: 'apple'}, {a: 'orange'}], ]); assert.deepStrictEqual(res, [{m: 'apple'}]); + + // Lexicographical tie breaking: 'a' < 'b' + var resTie = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 'b'}, {a: 'a'}, {a: 'a'}, {a: 'b'}]]); + assert.deepStrictEqual(resTie, [{m: 'a'}]); }); - it('4. Mode ignores NULL and undefined values when other values exist', function () { + it('5. Mode ignores NULL and undefined values when other values exist', function () { var res = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: 5}, {a: undefined}, {a: 5}]]); assert.deepStrictEqual(res, [{m: 5}]); + + var resMixed = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: 2}, {a: 2}, {a: 3}]]); + assert.deepStrictEqual(resMixed, [{m: 2}]); }); - it('5. All NULLs or undefined', function () { + it('6. All NULLs or undefined', function () { var resNull = alasql('SELECT MODE(a) AS m FROM ?', [[{a: null}, {a: null}]]); assert.deepStrictEqual(resNull, [{m: undefined}]); @@ -37,7 +52,7 @@ describe('Test 2482 - MODE aggregate function', function () { assert.deepStrictEqual(resMixed, [{m: undefined}]); }); - it('6. Boolean values', function () { + it('7. Boolean values and boolean ties', function () { var resTrue = alasql('SELECT MODE(a) AS m FROM ?', [[{a: true}, {a: false}, {a: true}]]); assert.deepStrictEqual(resTrue, [{m: true}]); @@ -49,7 +64,7 @@ describe('Test 2482 - MODE aggregate function', function () { assert.deepStrictEqual(resTie, [{m: false}]); }); - it('7. Strict Equality / Mixed Types', function () { + it('8. Strict Equality / Mixed Types', function () { // 1 and '1' are treated as distinct types var resNum = alasql('SELECT MODE(a) AS m FROM ?', [[{a: 1}, {a: '1'}, {a: 1}]]); assert.deepStrictEqual(resNum, [{m: 1}]); @@ -66,7 +81,7 @@ describe('Test 2482 - MODE aggregate function', function () { assert.deepStrictEqual(resBoolStr, [{m: true}]); }); - it('8. Negative Numbers and Floats', function () { + it('9. Negative Numbers and Floats', function () { var resNeg = alasql('SELECT MODE(a) AS m FROM ?', [[{a: -10}, {a: -20}, {a: -10}, {a: -30}]]); assert.deepStrictEqual(resNeg, [{m: -10}]); @@ -85,7 +100,13 @@ describe('Test 2482 - MODE aggregate function', function () { assert.deepStrictEqual(resNegTie, [{m: -10}]); }); - it('9. Mode with GROUP BY', function () { + it('10. Date values', function () { + var dates = [{d: '2024-01-01'}, {d: '2024-02-01'}, {d: '2024-01-01'}, {d: '2024-03-01'}]; + var res = alasql('SELECT MODE(d) AS m FROM ?', [dates]); + assert.deepStrictEqual(res, [{m: '2024-01-01'}]); + }); + + it('11. Mode with GROUP BY', function () { var data = [ {g: 1, v: 10}, {g: 1, v: 20}, @@ -95,17 +116,58 @@ describe('Test 2482 - MODE aggregate function', function () { {g: 2, v: 40}, {g: 3, v: null}, {g: 3, v: undefined}, + {g: 4, v: 'tie-b'}, + {g: 4, v: 'tie-a'}, ]; var res = alasql('SELECT g, MODE(v) AS m FROM ? GROUP BY g ORDER BY g', [data]); assert.deepStrictEqual(res, [ {g: 1, m: 20}, {g: 2, m: 30}, {g: 3, m: undefined}, + {g: 4, m: 'tie-a'}, ]); }); - it('10. Empty input returns undefined/null', function () { + it('12. Empty input returns undefined/null', function () { var res = alasql('SELECT MODE(a) AS m FROM ?', [[]]); assert.deepStrictEqual(res, [{m: undefined}]); }); + + it('13. Case sensitivity / aliases: mode and MODE resolve identically', function () { + var data = [{a: 1}, {a: 2}, {a: 2}]; + var resUpper = alasql('SELECT MODE(a) AS m FROM ?', [data]); + var resLower = alasql('SELECT mode(a) AS m FROM ?', [data]); + assert.deepStrictEqual(resUpper, [{m: 2}]); + assert.deepStrictEqual(resLower, [{m: 2}]); + assert.deepStrictEqual(resUpper, resLower); + }); + + it('14. Direct aggregate function lifecycle (stages 1, 2, 3, and unknown stage)', function () { + var fn = alasql.aggr.MODE; + // Stage 1: initialization + var s = fn(10, undefined, 1); + assert.strictEqual(s.maxCount, 1); + assert.strictEqual(s.counts.get(10), 1); + + // Stage 2: accumulation + s = fn(20, s, 2); + s = fn(20, s, 2); + assert.strictEqual(s.maxCount, 2); + assert.strictEqual(s.counts.get(20), 2); + + // Stage 2: null values ignored + s = fn(null, s, 2); + s = fn(undefined, s, 2); + + // Stage 3: finalization + var result = fn(undefined, s, 3); + assert.strictEqual(result, 20); + + // Stage 3: with all null accumulator + var nullAcc = fn(null, undefined, 1); + assert.strictEqual(fn(undefined, nullAcc, 3), undefined); + + // Unknown stage returns undefined + assert.strictEqual(fn(10, s, 99), undefined); + }); });