Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/guide/built-in-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/guide/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/csCZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'USEKNOUT',
UNICHAR: 'UNICHAR',
UNICODE: 'UNICODE',
UNIQUE: 'UNIQUE',
UPPER: 'VELKÁ',
VALUE: 'HODNOTA',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/daDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'AFKORT',
UNICHAR: 'UNICHAR',
UNICODE: 'UNICODE',
UNIQUE: 'ENTYDIGE',
UPPER: 'STORE.BOGSTAVER',
VALUE: 'VÆRDI',
VARA: 'VARIANSV',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/deDE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'KÜRZEN',
UNICHAR: 'UNIZEICHEN',
UNICODE: 'UNICODE',
UNIQUE: 'EINDEUTIG',
UPPER: 'GROSS',
VALUE: 'WERT',
VARA: 'VARIANZA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/enGB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'TRUNC',
UNICHAR: 'UNICHAR',
UNICODE: 'UNICODE',
UNIQUE: 'UNIQUE',
UPPER: 'UPPER',
VALUE: 'VALUE',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/esES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export const dictionary: RawTranslationPackage = {
TRUNC: 'TRUNCAR',
UNICHAR: 'UNICHAR',
UNICODE: 'UNICODE',
UNIQUE: 'UNICOS',
UPPER: 'MAYUSC',
VALUE: 'VALOR',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/fiFI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'KATKAISE',
UNICHAR: 'UNICODEMERKKI',
UNICODE: 'UNICODE',
UNIQUE: 'AINUTKERTAISET.ARVOT',
UPPER: 'ISOT',
VALUE: 'ARVO',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/frFR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'TRONQUE',
UNICHAR: 'UNICAR',
UNICODE: 'UNICODE',
UNIQUE: 'UNIQUE',
UPPER: 'MAJUSCULE',
VALUE: 'CNUM',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/huHU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'CSONK',
UNICHAR: 'UNIKARAKTER',
UNICODE: 'UNICODE',
UNIQUE: 'EGYEDI',
UPPER: 'NAGYBETŰS',
VALUE: 'ÉRTÉK',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/idID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'POTONG',
UNICHAR: 'UNIKARAKTER',
UNICODE: 'UNICODE',
UNIQUE: 'UNIQUE',
UPPER: 'HURUF.BESAR',
VALUE: 'NILAI',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/itIT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'TRONCA',
UNICHAR: 'CARATT.UNI',
UNICODE: 'UNICODE',
UNIQUE: 'UNICI',
UPPER: 'MAIUSC',
VALUE: 'VALORE',
VARA: 'VAR.VALORI',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nbNO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'AVKORT',
UNICHAR: 'UNICODETEGN',
UNICODE: 'UNICODE',
UNIQUE: 'UNIK',
UPPER: 'STORE',
VALUE: 'VERDI',
VARA: 'VARIANSA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nlNL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'GEHEEL',
UNICHAR: 'UNITEKEN',
UNICODE: 'UNICODE',
UNIQUE: 'UNIEK',
UPPER: 'HOOFDLETTERS',
VALUE: 'WAARDE',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/plPL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ptPT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'TRUNCAR',
UNICHAR: 'CARACTUNI',
UNICODE: 'UNICODE',
UNIQUE: 'EXCLUSIVOS',
UPPER: 'MAIÚSCULA',
VALUE: 'VALOR',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ruRU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'ОТБР',
UNICHAR: 'ЮНИСИМВ',
UNICODE: 'UNICODE',
UNIQUE: 'УНИК',
UPPER: 'ПРОПИСН',
VALUE: 'ЗНАЧ',
VARA: 'ДИСПА',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/svSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const dictionary: RawTranslationPackage = {
TRUNC: 'AVKORTA',
UNICHAR: 'UNICHAR',
UNICODE: 'UNICODE',
UNIQUE: 'UNIK',
UPPER: 'VERSALER',
VALUE: 'TEXTNUM',
VARA: 'VARA',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/trTR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
157 changes: 157 additions & 0 deletions src/interpreter/plugin/UniquePlugin.ts
Original file line number Diff line number Diff line change
@@ -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<UniquePlugin> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing empty-range crash guard

Medium Severity

UNIQUE can still produce a zero-width result when the input has height but no width (for example after addRows on an empty sheet, then a whole-row reference). That path builds [[]] and later hits ArrayValue, which rejects non-positive dimensions and throws. Sibling SORT already guards this case before building a range value.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a4097a4. Configure here.

}
)
}

/**
* 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
}
}
1 change: 1 addition & 0 deletions src/interpreter/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading