diff --git a/CHANGELOG.md b/CHANGELOG.md index fab7315b4..af942bd4b 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 the SORT function. [#1707](https://github.com/handsontable/hyperformula/pull/1707) - 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/guide/built-in-functions.md b/docs/guide/built-in-functions.md index a322bca16..51846e097 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]) | | SORT | Sorts the rows or columns of an array. | SORT(Array, [SortIndex], [SortOrder], [ByCol]) | ### Date and time diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 76a56c3d7..7f0e2361d 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,6 +38,11 @@ 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. + +* When `ExactlyOnce` is TRUE and no row or column occurs exactly once, `UNIQUE` returns a `#N/A` error (the result would otherwise be empty). ### SORT function * The `SortIndex` argument accepts a single key only. Multi-key sorting through an array constant (for example `=SORT(A1:B9, {1,2})`) is not supported; sort by one column or row at a time. diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 9e035a881..830b1c196 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -241,6 +241,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 6a9e78f7d..90296fd42 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -241,6 +241,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 1becb9679..daaaa0bf7 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -241,6 +241,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 0350eb314..d271b732c 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -243,6 +243,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 f446ad063..6fea52e4e 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -241,6 +241,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 554b12e33..a3b319af2 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -241,6 +241,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 81bcdd779..a29109053 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -241,6 +241,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 4d429523a..fafc2c4b9 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -241,6 +241,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 e5c8723cb..719cb11db 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -243,6 +243,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 210047149..40ec9cf41 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -241,6 +241,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 fcfa50c8f..89c0f85f7 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -241,6 +241,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 fcc0640c4..73ef99687 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -241,6 +241,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 8b42176d3..003b0fd64 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -241,6 +241,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 1d478543a..408d7f772 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -241,6 +241,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 856d94083..657b0db37 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -241,6 +241,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 ad27924ee..5f8758008 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -241,6 +241,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 c19796ed6..dc243600d 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -241,6 +241,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..5db1fb510 --- /dev/null +++ b/src/interpreter/plugin/UniquePlugin.ts @@ -0,0 +1,157 @@ +/** + * @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). + */ +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 => { + // v1 and v2 are always the same length here: they are rows (or columns, + // after transpose) of the same rectangular range, so no length check is + // needed before the element-wise comparison. + 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 a390ad843..2a3005375 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 {SortPlugin} from './SortPlugin' export {RoundingPlugin} from './RoundingPlugin' export {SqrtPlugin} from './SqrtPlugin'