diff --git a/CHANGELOG.md b/CHANGELOG.md index 94424f8a3..d61dd8054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698) - Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701) +- Added the UNIQUE function. [#1708](https://github.com/handsontable/hyperformula/pull/1708) - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) diff --git a/docs/adr/2026-07-13-sort-unique-array-functions.md b/docs/adr/2026-07-13-sort-unique-array-functions.md new file mode 100644 index 000000000..f5bdcb218 --- /dev/null +++ b/docs/adr/2026-07-13-sort-unique-array-functions.md @@ -0,0 +1,262 @@ +# ADR 2026-07-13 — SORT (HF-69) + UNIQUE (HF-68) dynamic array functions + +- **status**: accepted +- **date**: 2026-07-13 +- **tasks**: HF-69 (86c89q1tt) — SORT · HF-68 (86c89q1tq) — UNIQUE +- **parent**: HF-28 (86c6r57ke) — Modern dynamic array functions · **siblings**: SEQUENCE (shipped), VSTACK/HSTACK (HF-71, in review) +- **base branch**: clean `develop` (`00e5c730a`) — two independent feature branches [dec_1] + +## Context + +HF-28 tracks the modern dynamic-array family. SEQUENCE is shipped and is the anatomy exemplar: +a plugin declares `implementedFunctions` with `method`, a mandatory `sizeOfResultArrayMethod`, +and `vectorizationForbidden: true`; the runtime method returns a `SimpleRangeValue`/`CellError` +via `runFunction`, and the parse-time method returns an `ArraySize` [vrf_3]. FILTER is the +exemplar for a **dynamic-size** result: it predicts the input array size at parse time and +returns fewer rows at runtime, tolerating the size mismatch [vrf_4]. SORT and UNIQUE are +greenfield — no `SortPlugin`/`UniquePlugin` exists in `src/` [vrf_7]. + +Excel's documented contracts (authoritative, Microsoft Learn): + +- **SORT(array, [sort_index], [sort_order], [by_col])** — returns an array the **same shape** as + `array`. `sort_index` is a 1-based row/column number (default row1/col1); `sort_order` is `1` + ascending (default) or `-1` descending; `by_col` is `FALSE` (default, sort by row) or `TRUE` + (sort by column) [vrf_1]. +- **UNIQUE(array, [by_col], [exactly_once])** — `by_col` `FALSE` (default) compares rows and + returns unique rows, `TRUE` compares columns and returns unique columns; `exactly_once` `TRUE` + returns only rows/columns occurring exactly once, `FALSE` (default) returns all distinct + rows/columns. Result size is data-dependent (dynamic) [vrf_2]. + +HyperFormula already has an ordering comparator: `ArithmeticHelper` exposes `lt`/`gt`/`eq`, which +handle mixed types (numbers < strings < booleans via `CellValueTypeOrd`), empty cells, and +locale collation driven by `caseSensitive`/`accentSensitive` config [vrf_5]. Plugins register +automatically — every non-`_` export from `src/interpreter/plugin/index.ts` is registered as a +built-in [vrf_6] — so wiring is: add the plugin file, export it, and add the function name to all +17 i18n language packs. + +**Verification constraint.** This environment has no live Excel. Microsoft Learn documentation is +the oracle for documented behavior [vrf_1][vrf_2]. Behaviors **not** in the docs (notably the +reported "`sort_order=0` does not error" quirk) cannot be re-verified here and are **not** asserted +from memory — we adopt the strict documented contract and flag the quirk for live-Excel/Kuba +confirmation [con_1]. + +## Decisions + +- **Two separate PRs, one per ClickUp task, off clean `develop`** [dec_1]: Kuba tracks HF-68 and + HF-69 separately. SORT on `feature/HF-69-sort`, UNIQUE on `feature/HF-68-unique`, each with its + own paired `hyperformula-tests` PR. The shared ADR + plan are committed to both branches. + +- **`sort_order` strictly `{1, -1}`; any other value → `#VALUE!`** [dec_2]: Microsoft documents + only `1` and `-1` [vrf_1]. The reported `sort_order=0` non-error is undocumented and could not + be verified against live Excel here [con_1]; we choose the strict documented contract (simpler, + testable without Excel, defensible) and will revisit if live-Excel/Kuba confirms otherwise. + +- **Reuse `ArithmeticHelper.lt`/`gt` for SORT ordering and `eq` for UNIQUE equality** [dec_3]: no + new comparator. SORT's per-key comparator is `lt/gt` scaled by `sort_order`, with a **stable** + sort so ties preserve input order (matches Excel's stable behavior). UNIQUE dedups by comparing + rows/columns element-wise with `eq`, which is case-insensitive by default (Excel UNIQUE is + case-insensitive) and honors HF's collation config [dec_5]. + +- **Error-type map** [dec_4]: + - non-finite (`Infinity`/`NaN`) numeric arg → `#VALUE!` + - `sort_index` < 1 or exceeding the sort dimension → `#VALUE!` + - `sort_order` ∉ `{1,-1}` → `#VALUE!` (per [dec_2]) + - result exceeding `maxRows`/`maxColumns` → `#VALUE!` + - any error-typed argument → propagate automatically via `runFunction` + - wrong arity → `#N/A` automatically (arg-count validation) + +- **Single-key `sort_index` in v1; multi-key array-constant `sort_index` (e.g. `{1,2}`) deferred** + [dec_6]: the `sort_index` parameter is typed `NUMBER`; accepting an array constant would require + a different argument shape. v1 sorts by one key; multi-key is recorded in + `known-limitations.md` and left for a follow-up. Concrete error messages: `sort_index < 1` → + `LessThanOne`; `sort_index >` the sort dimension → `ValueLarge`; `sort_order ∉ {1,-1}` → + `BadMode`. + +- **Propagate the first error found in the input range** [dec_7]: `lt`/`gt`/`eq` operate on + no-error scalars, and Excel's exact in-array error behavior is unverifiable here [con_1]. SORT and + UNIQUE scan the input and return the first `CellError` encountered, consistent with the + "error arg → propagate" rule extended to range contents [dec_4]. + +- **Empty UNIQUE result → `#NA` (`EmptyRange`)** [dec_8]: only reachable via `exactly_once` when no + row/column occurs exactly once. Excel returns `#CALC!`, which HyperFormula has no type for; we + mirror FILTER's empty-result mapping (`ErrorType.NA` / `ErrorMessage.EmptyRange`) for consistency + with the sibling dynamic-size function. + +- **Result-size prediction** [con_3]: SORT output shape equals input shape → parse-time size = + `arraySizeForAst(array)` (deterministic). UNIQUE output size is data-dependent → mirror FILTER: + predict the input size as an upper bound and return the smaller actual result at runtime + [vrf_4]. + +## Consequences + +- **Positive**: both functions reuse existing, tested infrastructure (comparator, `runFunction`, + array-size machinery); behavior for mixed types/empties/collation is consistent with the rest of + HF automatically; auto-registration means minimal wiring. +- **Neutral / follow-ups**: the strict `sort_order` contract [dec_2] and any other Excel divergence + are surfaced in three places (PR Notes, inline comment, test name/comment) and recorded in + `docs/guide/known-limitations.md` / `list-of-differences.md` where behavior diverges. The + `sort_order=0` quirk is an open live-Excel verification item, not a shipped guarantee [con_1]. +- **Negative**: without live Excel, tie-breaking on mixed types and the exact collation of UNIQUE + are validated against HF's own comparator semantics + MS docs, not a byte-for-byte Excel oracle; + xlsx oracle fixtures are provided so a human/graph-runner with Excel can confirm later. + +## Alternatives considered + +1. **One combined PR for both functions** — rejected: Kuba tracks HF-68 and HF-69 as separate + ClickUp tasks; combined delivery breaks his tracking [dec_1]. +2. **Write a bespoke SORT comparator** — rejected: `ArithmeticHelper.lt/gt/eq` already encodes + Excel-consistent cross-type ordering, empties, and collation [vrf_5]; a bespoke comparator would + drift from the rest of the engine [dec_3]. +3. **Treat `sort_order=0` as valid ascending** (matching the reported Excel quirk) — rejected: the + quirk is undocumented and unverifiable in this environment [con_1]; asserting it from memory + would violate feature-traceability. Strict documented contract chosen [dec_2]. +4. **Predict a fixed 1×1 size for UNIQUE** — rejected: FILTER's input-size-upper-bound prediction + is the established dynamic-size pattern and spills correctly [vrf_4][con_3]. +5. **Make UNIQUE case-sensitive** — rejected: Excel UNIQUE is case-insensitive and HF's `eq` + default (`caseSensitive: false`) matches it while staying config-driven [dec_5]. + +## §AuditSources + +[vrf_1] Microsoft documents SORT(array,[sort_index],[sort_order],[by_col]); result same shape as array; sort_order 1=ascending(default)/-1=descending; by_col FALSE(default)=by row +- type: live-http +- spec: https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c +- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/sort-function-22f63bd0-ccc8-492f-953d-c20e8e44b86c" +- expect: ^200$ +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_2] Microsoft documents UNIQUE(array,[by_col],[exactly_once]); by_col FALSE=unique rows; exactly_once TRUE=rows/cols occurring exactly once; dynamic result size +- type: live-http +- spec: https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e +- verify: curl -sSL -o /dev/null -w "%{http_code}" "https://support.microsoft.com/en-us/office/unique-function-c5ab87fd-30a3-4ce9-9d1a-40204fb85e1e" +- expect: ^200$ +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_3] SEQUENCE plugin is the anatomy exemplar: sizeOfResultArrayMethod + vectorizationForbidden:true +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/plugin/SequencePlugin.ts@feature/HF-69-sort +- verify: grep -E "sizeOfResultArrayMethod|vectorizationForbidden" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/SequencePlugin.ts +- expect: vectorizationForbidden +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_4] FILTER is the dynamic-size exemplar: parse-time size method returns an ArraySize from input, runtime returns fewer rows +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/plugin/ArrayPlugin.ts@feature/HF-69-sort +- verify: grep -E "filterArraySize|new ArraySize\(width, height\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/plugin/ArrayPlugin.ts +- expect: filterArraySize +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_5] ArithmeticHelper exposes lt/gt/eq comparators handling mixed types, empties, and collation +- type: repo-file +- spec: handsontable/hyperformula:src/interpreter/ArithmeticHelper.ts@feature/HF-69-sort +- verify: grep -E "public (lt|gt|eq) =" /workspaces/hyperformula-worktrees/HF-69-sort/src/interpreter/ArithmeticHelper.ts +- expect: public lt = +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_6] Plugins auto-register: every non-underscore export from interpreter/plugin/index.ts is registered as a built-in +- type: repo-file +- spec: handsontable/hyperformula:src/index.ts@feature/HF-69-sort +- verify: grep -E "registerFunctionPlugin\(plugins\[pluginName\]\)" /workspaces/hyperformula-worktrees/HF-69-sort/src/index.ts +- expect: registerFunctionPlugin +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[vrf_7] SORT/UNIQUE are greenfield: no SortPlugin or UniquePlugin class exists in src +- type: command +- spec: repository source tree @feature/HF-69-sort +- verify: grep -rlE "class SortPlugin|class UniquePlugin" /workspaces/hyperformula-worktrees/HF-69-sort/src/ || echo NONE +- expect: NONE +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_1] Two separate PRs, one per ClickUp task, each off clean develop +- type: transcript +- spec: HF-68/HF-69 handoff (Delivery) — Kuba tracks HF-68 and HF-69 separately — https://app.clickup.com/t/86c89q1tt , https://app.clickup.com/t/86c89q1tq +- verify: echo "Manual — handoff Delivery line: two separate PRs, not combined" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_2] sort_order strictly {1,-1}; any other value yields #VALUE! +- type: transcript +- spec: this ADR §Decisions; documented contract per vrf_1; undocumented quirk deferred per con_1 +- verify: echo "Manual — strict documented contract chosen; sort_order=0 quirk unverifiable, deferred" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_3] Reuse ArithmeticHelper.lt/gt for SORT ordering and eq for UNIQUE equality (stable sort for ties) +- type: transcript +- spec: this ADR §Decisions; comparator exists per vrf_5 +- verify: echo "Manual — reuse existing comparator; no bespoke ordering" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_4] Error-type map: non-finite/negative-zero-sort_index/over-limit -> #VALUE!; error arg propagates; wrong arity -> #N/A +- type: transcript +- spec: HF-68/HF-69 handoff (Stage 2 error-type map) + this ADR §Decisions +- verify: echo "Manual — error map per handoff Stage 2" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_5] UNIQUE equality uses eq (case-insensitive by default, config-driven), matching Excel's case-insensitive UNIQUE +- type: transcript +- spec: this ADR §Decisions; eq semantics per vrf_5 +- verify: echo "Manual — UNIQUE case-insensitive by default via eq, honors caseSensitive config" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_6] Single-key sort_index in v1; multi-key array-constant sort_index deferred; error messages LessThanOne / ValueLarge / BadMode +- type: transcript +- spec: this ADR §Decisions; sort_index typed NUMBER; multi-key needs a different arg shape +- verify: echo "Manual — v1 single-key sort_index; multi-key deferred to known-limitations" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[dec_7] SORT and UNIQUE propagate the first CellError found in the input range +- type: transcript +- spec: this ADR §Decisions; lt/gt/eq operate on no-error scalars; extends dec_4 to range contents +- verify: echo "Manual — propagate first in-range error; Excel in-array error behavior unverifiable" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[dec_8] Empty UNIQUE result (exactly_once with no once-only row/col) -> #NA (EmptyRange), mirroring FILTER +- type: transcript +- spec: this ADR §Decisions; Excel returns #CALC! (no HF equivalent); mirror FILTER empty-result mapping +- verify: echo "Manual — empty UNIQUE -> NA/EmptyRange like FILTER" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[con_1] No live Excel in this environment; MS docs are the oracle; undocumented quirks (sort_order=0) flagged for live-Excel/Kuba, not asserted from memory +- type: transcript +- spec: this ADR §Context (Verification constraint) +- verify: echo "Manual — no live Excel; documented behavior only; quirks deferred to live-Excel verification" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z + +[con_2] Dual test env (jest + karma/Jasmine): no it.each / toHaveLength / arrayContaining / fs; specs must compile with tsconfig.test.json +- type: transcript +- spec: HF-68/HF-69 handoff (Stage 3 dual test env) + reference_dual_test_env_jasmine +- verify: echo "Manual — dual-env spec constraints per handoff Stage 3" +- expect: . +- asserted-by: marcin-kordas-hoc +- asserted-at: 2026-07-13T12:30:00Z + +[con_3] SORT output shape = input shape (deterministic size); UNIQUE dynamic size mirrors FILTER (input size as upper bound) +- type: transcript +- spec: this ADR §Decisions; FILTER pattern per vrf_4 +- verify: echo "Manual — SORT deterministic size; UNIQUE dynamic, mirrors FILTER" +- expect: . +- asserted-by: claude-opus-4-8 +- asserted-at: 2026-07-13T12:30:00Z diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index d486c8e04..ca1fb93a4 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -67,6 +67,7 @@ Total number of functions: **{{ $page.functionsCount }}** | SEQUENCE | Returns an array of sequential numbers. | SEQUENCE(Rows, [Cols], [Start], [Step]) | | VSTACK | Stacks arrays vertically into a single array. | VSTACK(Array1, [Array2], ...[ArrayN]) | | HSTACK | Stacks arrays horizontally into a single array. | HSTACK(Array1, [Array2], ...[ArrayN]) | +| UNIQUE | Returns the unique rows or columns of an array. | UNIQUE(Array, [ByCol], [ExactlyOnce]) | ### Date and time diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index c4a1bbc61..144c7a029 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,6 +38,12 @@ a circular reference. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. +### UNIQUE function + +* Comparison of values follows HyperFormula's own equality rules, which honor the `caseSensitive` and `accentSensitive` configuration options. By default comparison is case-insensitive, matching Excel's UNIQUE. + +* When `ExactlyOnce` is TRUE and no row or column occurs exactly once, `UNIQUE` returns a `#N/A` error (the result would otherwise be empty). + ### OFFSET function HyperFormula resolves the OFFSET function at parse time rather than during evaluation. The parser inspects the arguments and rewrites the expression into a plain cell reference or range. This keeps the dependency graph accurate but imposes several restrictions. diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index ff9649112..d123419d1 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'USEKNOUT', UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', + UNIQUE: 'UNIQUE', UPPER: 'VELKÁ', VALUE: 'HODNOTA', VARA: 'VARA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6a44e95ce..c3714e69d 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'AFKORT', UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', + UNIQUE: 'ENTYDIGE', UPPER: 'STORE.BOGSTAVER', VALUE: 'VÆRDI', VARA: 'VARIANSV', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 7633eb14f..08574d9f3 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'KÜRZEN', UNICHAR: 'UNIZEICHEN', UNICODE: 'UNICODE', + UNIQUE: 'EINDEUTIG', UPPER: 'GROSS', VALUE: 'WERT', VARA: 'VARIANZA', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 7011a8162..74abbe1d4 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -242,6 +242,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'TRUNC', UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', + UNIQUE: 'UNIQUE', UPPER: 'UPPER', VALUE: 'VALUE', VARA: 'VARA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 4d3a8217f..6a9942de1 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -240,6 +240,7 @@ export const dictionary: RawTranslationPackage = { TRUNC: 'TRUNCAR', UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', + UNIQUE: 'UNICOS', UPPER: 'MAYUSC', VALUE: 'VALOR', VARA: 'VARA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 55f13bd03..728eb79ec 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'KATKAISE', UNICHAR: 'UNICODEMERKKI', UNICODE: 'UNICODE', + UNIQUE: 'AINUTKERTAISET.ARVOT', UPPER: 'ISOT', VALUE: 'ARVO', VARA: 'VARA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index de69b12ec..148bfe7b0 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'TRONQUE', UNICHAR: 'UNICAR', UNICODE: 'UNICODE', + UNIQUE: 'UNIQUE', UPPER: 'MAJUSCULE', VALUE: 'CNUM', VARA: 'VARA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 200787a5e..b0ad90b12 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'CSONK', UNICHAR: 'UNIKARAKTER', UNICODE: 'UNICODE', + UNIQUE: 'EGYEDI', UPPER: 'NAGYBETŰS', VALUE: 'ÉRTÉK', VARA: 'VARA', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 72e69bb05..f760dcf01 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -242,6 +242,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'POTONG', UNICHAR: 'UNIKARAKTER', UNICODE: 'UNICODE', + UNIQUE: 'UNIQUE', UPPER: 'HURUF.BESAR', VALUE: 'NILAI', VARA: 'VARA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 93a6f9a69..a504b7b92 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'TRONCA', UNICHAR: 'CARATT.UNI', UNICODE: 'UNICODE', + UNIQUE: 'UNICI', UPPER: 'MAIUSC', VALUE: 'VALORE', VARA: 'VAR.VALORI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index a0c9b9e47..83a38aa81 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'AVKORT', UNICHAR: 'UNICODETEGN', UNICODE: 'UNICODE', + UNIQUE: 'UNIK', UPPER: 'STORE', VALUE: 'VERDI', VARA: 'VARIANSA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index f8c1eed96..f258d0760 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'GEHEEL', UNICHAR: 'UNITEKEN', UNICODE: 'UNICODE', + UNIQUE: 'UNIEK', UPPER: 'HOOFDLETTERS', VALUE: 'WAARDE', VARA: 'VARA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index b6b8aa5ec..8303c5445 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'LICZBA.CAŁK', UNICHAR: 'ZNAK.UNICODE', UNICODE: 'UNICODE', + UNIQUE: 'UNIKATOWE', UPPER: 'LITERY.WIELKIE', VALUE: 'WARTOŚĆ', VARA: 'WARIANCJA.A', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3d9386d9f..1df782eec 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'TRUNCAR', UNICHAR: 'CARACTUNI', UNICODE: 'UNICODE', + UNIQUE: 'EXCLUSIVOS', UPPER: 'MAIÚSCULA', VALUE: 'VALOR', VARA: 'VARA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index c247b3050..7b3433b24 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'ОТБР', UNICHAR: 'ЮНИСИМВ', UNICODE: 'UNICODE', + UNIQUE: 'УНИК', UPPER: 'ПРОПИСН', VALUE: 'ЗНАЧ', VARA: 'ДИСПА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 13fae5523..6bb5afecb 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'AVKORTA', UNICHAR: 'UNICHAR', UNICODE: 'UNICODE', + UNIQUE: 'UNIK', UPPER: 'VERSALER', VALUE: 'TEXTNUM', VARA: 'VARA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 8196edc45..10ab2f529 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -240,6 +240,7 @@ const dictionary: RawTranslationPackage = { TRUNC: 'NSAT', UNICHAR: 'UNICODEKARAKTERİ', UNICODE: 'UNICODE', + UNIQUE: 'BENZERSİZ', UPPER: 'BÜYÜKHARF', VALUE: 'DEĞER', VARA: 'VARA', diff --git a/src/interpreter/plugin/UniquePlugin.ts b/src/interpreter/plugin/UniquePlugin.ts new file mode 100644 index 000000000..7fc61019a --- /dev/null +++ b/src/interpreter/plugin/UniquePlugin.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {ArraySize} from '../../ArraySize' +import {CellError, ErrorType} from '../../Cell' +import {ErrorMessage} from '../../error-message' +import {ProcedureAst} from '../../parser' +import {InterpreterState} from '../InterpreterState' +import {InternalNoErrorScalarValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' + +/** + * Plugin implementing the UNIQUE spreadsheet function. + * + * UNIQUE(array, [by_col], [exactly_once]) returns the distinct rows (or columns + * when by_col is TRUE) of `array`, preserving first-occurrence order. When + * exactly_once is TRUE, only rows/columns that occur exactly once are returned. + * Equality delegates to {@link ArithmeticHelper}, so comparison honors the + * caseSensitive/accentSensitive configuration (case-insensitive by default, + * matching Excel). + */ +export class UniquePlugin extends FunctionPlugin implements FunctionPluginTypecheck { + public static implementedFunctions: ImplementedFunctions = { + 'UNIQUE': { + method: 'unique', + sizeOfResultArrayMethod: 'uniqueArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: false, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: false, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, + } + + /** + * Corresponds to UNIQUE(array, [by_col], [exactly_once]). + * + * Errors found anywhere in the input range are propagated. An empty result + * (only reachable via exactly_once when nothing occurs exactly once) yields + * #N/A, mirroring FILTER's empty-result handling. + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public unique(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('UNIQUE'), + (range: SimpleRangeValue, byCol: boolean, exactlyOnce: boolean) => { + const data = range.data + + const firstError = UniquePlugin.findFirstError(data) + if (firstError !== undefined) { + return firstError + } + + // Work in "vectors": rows for the default, columns when by_col is TRUE. + const vectors: InternalScalarValue[][] = byCol + ? UniquePlugin.transpose(data) + : data.map(row => row.slice()) + + const equalVectors = (v1: InternalScalarValue[], v2: InternalScalarValue[]): boolean => { + if (v1.length !== v2.length) { + return false + } + for (let i = 0; i < v1.length; i++) { + if (!this.arithmeticHelper.eq(v1[i] as InternalNoErrorScalarValue, v2[i] as InternalNoErrorScalarValue)) { + return false + } + } + return true + } + + // Preserve first-occurrence order; count occurrences for exactly_once. + // Deduplication is O(n^2) in the number of vectors: equality is locale-aware + // (via arithmeticHelper.eq) and not trivially hashable, so each vector is + // compared against the distinct ones found so far. This matches Excel's + // observable behavior; for very large inputs it is the known cost. + const distinct: InternalScalarValue[][] = [] + const counts: number[] = [] + for (const vector of vectors) { + const existing = distinct.findIndex(d => equalVectors(d, vector)) + if (existing === -1) { + distinct.push(vector) + counts.push(1) + } else { + counts[existing] += 1 + } + } + + const kept = exactlyOnce + ? distinct.filter((_, i) => counts[i] === 1) + : distinct + + if (kept.length === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + } + + const result = byCol ? UniquePlugin.transpose(kept) : kept + return SimpleRangeValue.onlyValues(result) + } + ) + } + + /** + * Predicts the output array size for UNIQUE at parse time. + * The size is data-dependent, so we predict the input size as an upper bound + * (mirroring FILTER) and return the smaller actual result at runtime. A fresh + * ArraySize is returned so the input's `isRef` flag is not propagated (an + * ArraySize flagged as a ref is treated as scalar, which would collapse the + * spilled result into a single cell). + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public uniqueArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1 || ast.args.length > 3) { + return ArraySize.error() + } + const metadata = this.metadata('UNIQUE') + const subChecks = ast.args.map((arg) => + this.arraySizeForAst(arg, new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)))) + return new ArraySize(subChecks[0].width, subChecks[0].height) + } + + /** Returns the first {@link CellError} found in a 2-D array, or undefined. */ + private static findFirstError(data: InternalScalarValue[][]): CellError | undefined { + for (const row of data) { + for (const cell of row) { + if (cell instanceof CellError) { + return cell + } + } + } + return undefined + } + + /** Transposes a 2-D array (rows <-> columns). */ + private static transpose(data: InternalScalarValue[][]): InternalScalarValue[][] { + if (data.length === 0) { + return [] + } + const height = data.length + const width = data[0].length + const result: InternalScalarValue[][] = [] + for (let c = 0; c < width; c++) { + const col: InternalScalarValue[] = [] + for (let r = 0; r < height; r++) { + col.push(data[r][c]) + } + result.push(col) + } + return result + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index e86ba4f2a..89507abdf 100644 --- a/src/interpreter/plugin/index.ts +++ b/src/interpreter/plugin/index.ts @@ -36,6 +36,7 @@ export {RadiansPlugin} from './RadiansPlugin' export {RadixConversionPlugin} from './RadixConversionPlugin' export {RandomPlugin} from './RandomPlugin' export {SequencePlugin} from './SequencePlugin' +export {UniquePlugin} from './UniquePlugin' export {RoundingPlugin} from './RoundingPlugin' export {SqrtPlugin} from './SqrtPlugin' export {ConditionalAggregationPlugin} from './ConditionalAggregationPlugin'