Skip to content

feat: add MODE aggregate function (#2482) - #2519

Open
Abhirup0 wants to merge 3 commits into
AlaSQL:developfrom
Abhirup0:feat/add-mode-aggregate-function
Open

feat: add MODE aggregate function (#2482)#2519
Abhirup0 wants to merge 3 commits into
AlaSQL:developfrom
Abhirup0:feat/add-mode-aggregate-function

Conversation

@Abhirup0

Copy link
Copy Markdown

Closes #2482

Summary

Adds the MODE() aggregate function to AlaSQL as requested in #2482.

Details

  • Implemented aggregate function alasql.aggr.MODE (and alias alasql.aggr.mode) in src/55functions.js.
  • Ignores NULL and undefined values during aggregation.
  • Accumulates frequency counts via Map.
  • Resolves ties by returning the smallest value (ANSI SQL semantics).
  • Added test suite test/test999_mode.js covering numbers, strings, tie-breaking, GROUP BY, and empty sets.

Comment thread test/test999_mode.js Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename file to test2482.js

Comment thread test/test999_mode.js Outdated
var alasql = require('..');
}

describe('Test MODE aggregate function', function () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add test cases that covers

  • All NULLs or undefined
  • Boolean values
  • Strict Equality / Mixed Types: Does MODE treat the number 1 and the string '1' as the same or different?
  • Negative Numbers and Floats

@mathiasrw

Copy link
Copy Markdown
Member

Thank you for looking into that. As I am not familiar with using MODE for SQL I asked for a bit of input on the PR. Please consider this an inspiration for improvememnts and not everything as a demand.


Recommendation

Improve the following:

  1. Handle stage === 3 explicitly.
  2. Count incrementally in a Map, rather than retaining all values.
  3. Specify tie behavior in the docs and tests.
  4. Define supported input domains—ideally numbers, strings, dates, and booleans—or provide a shared AlaSQL comparison helper if one exists.
  5. Add grouped, null, empty, duplicate, and tie tests.

Details

Lifecycle: correct, but make stage 3 explicit

The proposed code implicitly treats “anything other than stage 1 or 2” as finalization:

if (!s.length) {
  return undefined;
}

That works as long as the engine only ever invokes stages 1, 2, and 3, but the conventional AlaSQL implementation should make finalization explicit:

if (stage === 3) {
  // finalize
}

Then either return undefined or throw for an unknown stage. This makes the contract self-documenting and avoids a future engine change silently being interpreted as finalization.

AlaSQL’s own user-defined aggregate example describes stage === 3 as post-processing and notes that value is undefined at that point. github

Null behavior: likely correct SQL semantics

The implementation ignores both undefined and null:

if (v !== undefined && v !== null) {
  s.push(v);
}

That is a sensible choice for a statistical-style MODE() aggregate: SQL aggregates generally ignore NULL, and an all-null group should yield NULL/undefined rather than selecting null as the modal value.

The one caveat is AlaSQL’s established quirks and compatibility expectations. The project’s MAX() documentation explicitly says its built-in behavior may treat null as 0, while suggesting custom aggregates for cleaner null handling. For a new MODE, I would document the intended semantics clearly: github

  • MODE(NULL, NULL)undefined / SQL NULL
  • MODE(1, NULL, 1, 2)1
  • MODE() on an empty input → undefined / SQL NULL

Main issue: accumulator cost

This code stores every non-null input in an array, then creates a Map and traverses the full array during stage 3:

s.push(v);       // stages 1 and 2
// ...
for (let i = 0; i < s.length; i++) {
  // count later
}

It is functionally fine, but it needs (O(n)) retained memory per group, which can be expensive for large grouped datasets. MODE inherently needs frequency state, but it does not need the raw full list. Accumulate counts incrementally instead.

That changes the working memory from (O(n)) for all input values to (O(k)), where (k) is the number of distinct non-null values in the group.

Tie-breaking needs a deliberate spec

The proposal returns the smallest candidate after sorting:

candidates.sort((a, b) => {
  if (a < b) return -1;
  if (a > b) return 1;
  return 0;
});
return candidates[0];

That is deterministic for ordinary homogeneous numbers or strings, which is good. But it needs a documented policy because “mode” is not inherently single-valued: multiple values can have the same highest frequency.

The problem is that JavaScript’s < / > comparison does not define a robust SQL ordering across mixed types:

  • 1 versus '1' are distinct Map keys, but comparisons coerce during < / >.
  • Objects, arrays, dates, NaN, BigInt, and other AlaSQL-supported values have unclear or potentially surprising behavior.
  • Distinct values may compare neither less nor greater, producing a comparator result of 0.

You should decide one of these API choices:

Policy Result for MODE(1, 1, 2, 2) Notes
Lowest comparable value 1 Useful and deterministic for same-type scalar values; requires a type/order specification.
First value reaching max frequency 1 or 2, based on row order Simple, but SQL input ordering is usually not guaranteed.
First encountered among tied candidates 1 Stable relative to input traversal, but still order-dependent.
Return all modes [1, 2] Mathematically complete but changes the aggregate’s result type and SQL ergonomics.
Return NULL on ties NULL Unambiguous but potentially surprising.

For a scalar MODE() aggregate, “minimum under AlaSQL’s comparable scalar ordering” is reasonable—but it should be implemented and tested as an explicit product decision, not merely an incidental JS comparator.

Suggested implementation

This keeps the same user-visible behavior—ignore nullish values, return undefined for no usable values, and choose the smallest tied scalar—but counts during accumulation and uses an explicit stage 3:

alasql.aggr.mode = alasql.aggr.MODE = function (v, s, stage) {
  if (stage === 1) {
    if (v == null) {
      return {
        counts: new Map(),
        maxCount: 0
      };
    }

    return {
      counts: new Map([[v, 1]]),
      maxCount: 1
    };
  }

  if (stage === 2) {
    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;
  }

  if (stage === 3) {
    if (!s || s.maxCount === 0) {
      return undefined;
    }

    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;
      }
    }

    return result;
  }

  return undefined;
};

This avoids creating both a candidates array and a sorting pass. Its finalization is (O(k)), rather than (O(k \log k)) for sorting the ties, and it never retains duplicate input values.

Important edge cases to test

At minimum, I would require tests for:

-- One value
SELECT MODE(v) FROM ?             -- [{ v: 42 }]
-- 42

-- Typical mode
SELECT MODE(v) FROM ?             -- [{ v: 1 }, { v: 2 }, { v: 2 }]
-- 2

-- Tie, assuming lowest-value policy
SELECT MODE(v) FROM ?             -- [{ v: 2 }, { v: 1 }, { v: 1 }, { v: 2 }]
-- 1

-- Null values ignored
SELECT MODE(v) FROM ?             -- [{ v: null }, { v: 2 }, { v: 2 }, { v: 3 }]
-- 2

-- Entirely nullish input
SELECT MODE(v) FROM ?             -- [{ v: null }, { v: undefined }]
-- undefined / SQL NULL

-- Grouped aggregation
SELECT category, MODE(v)
FROM ?
GROUP BY category;

-- Strings
SELECT MODE(v) FROM ?             -- ['b', 'a', 'a', 'b']
-- 'a', if lexicographic-lowest tie-breaking is the contract

Also add a test that verifies mode and MODE resolve identically. The alias assignment is consistent with exposing SQL aggregate names case-insensitively, although AlaSQL documentation traditionally shows user-defined aggregates registered in alasql.aggr under one name. github

@Abhirup0

Copy link
Copy Markdown
Author

Thanks a lot for the detailed feedback @mathiasrw! I'm pretty new to contributing here, so I really appreciate you taking the time to guide me through this and share such helpful suggestions.

I've just pushed an update addressing all your points:

  • Incremental counting: Switched the accumulator to count directly in a Map instead of storing all row values in an array, keeping memory at $O(k)$ distinct values.
  • Explicit lifecycle: Added explicit checks for stage === 1, 2, and 3 (and returning undefined for unknown stages).
  • Tie-breaking: Implemented the lowest comparable value policy (value < result) evaluated in a single pass during finalization.
  • Expanded tests: Added tests in test2482.js covering ties across numbers/strings/booleans, dates, null/undefined skipping, empty inputs, GROUP BY, case-insensitivity (mode vs MODE), and direct lifecycle calls.

Please let me know if anything else needs adjustments!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add MODE function

2 participants