diff --git a/CHANGELOG.md b/CHANGELOG.md index 94424f8a3..fab7315b4 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 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 d486c8e04..a322bca16 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]) | +| 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 c4a1bbc61..76a56c3d7 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -38,6 +38,14 @@ 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. +### 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. + +* The `SortOrder` argument must be exactly `1` (ascending) or `-1` (descending). Any other value returns a `#VALUE!` error. + +* Ordering (including mixed types, empty cells, and text collation) follows HyperFormula's own comparison rules, which honor the `caseSensitive` and `accentSensitive` configuration options. Numbers sort before text, and text before logical values. + ### 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..9e035a881 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'ODPIS.LIN', + SORT: 'SORT', SPLIT: 'SPLIT', SQRT: 'ODMOCNINA', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 6a44e95ce..6a9e78f7d 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LA', + SORT: 'SORTER', SPLIT: 'SPLIT', SQRT: 'KVROD', STDEVA: 'STDAFVV', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 7633eb14f..1becb9679 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINHYP', SLN: 'LIA', + SORT: 'SORTIEREN', SPLIT: 'SPLIT', SQRT: 'WURZEL', STDEVA: 'STABWA', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 7011a8162..0350eb314 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -210,6 +210,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'SLN', + SORT: 'SORT', SPLIT: 'SPLIT', SQRT: 'SQRT', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 4d3a8217f..f446ad063 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -208,6 +208,7 @@ export const dictionary: RawTranslationPackage = { SIN: 'SENO', SINH: 'SENOH', SLN: 'SLN', + SORT: 'ORDENAR', SPLIT: 'SPLIT', SQRT: 'RAIZ', STDEVA: 'DESVESTA', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 55f13bd03..554b12e33 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'STP', + SORT: 'LAJITTELE', SPLIT: 'SPLIT', SQRT: 'NELIÖJUURI', STDEVA: 'KESKIHAJONTAA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index de69b12ec..81bcdd779 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'AMORLIN', + SORT: 'TRIER', SPLIT: 'SPLIT', SQRT: 'RACINE', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 200787a5e..4d429523a 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LCSA', + SORT: 'SORBA.RENDEZ', SPLIT: 'SPLIT', SQRT: 'GYÖK', STDEVA: 'SZÓRÁSA', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 72e69bb05..e5c8723cb 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -210,6 +210,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'GSL', + SORT: 'SORT', SPLIT: 'PISAH', SQRT: 'AKAR', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 93a6f9a69..210047149 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SEN', SINH: 'SENH', SLN: 'AMMORT.COST', + SORT: 'DATI.ORDINA', SPLIT: 'SPLIT', SQRT: 'RADQ', STDEVA: 'DEV.ST.VALORI', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index a0c9b9e47..fcfa50c8f 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LINAVS', + SORT: 'SORTER', SPLIT: 'SPLIT', SQRT: 'ROT', STDEVA: 'STDAVVIKA', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index f8c1eed96..fcc0640c4 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LIN.AFSCHR', + SORT: 'SORTEREN', SPLIT: 'SPLIT', SQRT: 'WORTEL', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index b6b8aa5ec..8b42176d3 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'SLN', + SORT: 'SORTUJ', SPLIT: 'PODZIEL.TEKST', SQRT: 'PIERWIASTEK', STDEVA: 'ODCH.STANDARDOWE.A', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3d9386d9f..1d478543a 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SEN', SINH: 'SENH', SLN: 'DPD', + SORT: 'ORDENAR', SPLIT: 'SPLIT', SQRT: 'RAIZ', STDEVA: 'DESVPADA', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index c247b3050..856d94083 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'АПЛ', + SORT: 'СОРТ', SPLIT: 'SPLIT', SQRT: 'КОРЕНЬ', STDEVA: 'СТАНДОТКЛОНА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 13fae5523..ad27924ee 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SIN', SINH: 'SINH', SLN: 'LINAVSKR', + SORT: 'SORTERA', SPLIT: 'SPLIT', SQRT: 'ROT', STDEVA: 'STDEVA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 8196edc45..c19796ed6 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -208,6 +208,7 @@ const dictionary: RawTranslationPackage = { SIN: 'SİN', SINH: 'SINH', SLN: 'DA', + SORT: 'SIRALA', SPLIT: 'SPLIT', SQRT: 'KAREKÖK', STDEVA: 'STDSAPMAA', diff --git a/src/interpreter/plugin/SortPlugin.ts b/src/interpreter/plugin/SortPlugin.ts new file mode 100644 index 000000000..088bd275e --- /dev/null +++ b/src/interpreter/plugin/SortPlugin.ts @@ -0,0 +1,162 @@ +/** + * @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 {EmptyValue, InternalNoErrorScalarValue, InternalScalarValue, InterpreterValue} from '../InterpreterValue' +import {SimpleRangeValue} from '../../SimpleRangeValue' +import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' + +/** + * Plugin implementing the SORT spreadsheet function. + * + * SORT(array, [sort_index], [sort_order], [by_col]) returns the elements of + * `array` sorted along one dimension. The returned array has the same shape as + * the input. By default it reorders rows using the first column as the key, + * ascending. Ordering is delegated to {@link ArithmeticHelper} so that mixed + * types, empty cells, and locale collation behave exactly as elsewhere in the + * engine. + */ +export class SortPlugin extends FunctionPlugin implements FunctionPluginTypecheck { + public static implementedFunctions: ImplementedFunctions = { + 'SORT': { + method: 'sort', + sizeOfResultArrayMethod: 'sortArraySize', + enableArrayArithmeticForArguments: true, + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 1, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.BOOLEAN, defaultValue: false, emptyAsDefault: true}, + ], + vectorizationForbidden: true, + }, + } + + /** + * Corresponds to SORT(array, [sort_index], [sort_order], [by_col]). + * + * `sort_order` is validated strictly to {1, -1} (the documented contract); + * any other value yields #VALUE!. `sort_index` is a 1-based index into the sort + * dimension (columns when sorting rows, rows when `by_col` is TRUE). Errors found + * anywhere in the input range are propagated. + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public sort(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('SORT'), + (range: SimpleRangeValue, sortIndex: number, sortOrder: number, byCol: boolean) => { + if (sortOrder !== 1 && sortOrder !== -1) { + return new CellError(ErrorType.VALUE, ErrorMessage.BadMode) + } + + const data = range.data + const height = range.height() + const width = range.width() + + const firstError = SortPlugin.findFirstError(data) + if (firstError !== undefined) { + return firstError + } + + // An empty input range (e.g. a whole-column reference to an empty sheet) + // yields no rows/columns to sort. Return #N/A rather than letting the empty + // 2-D array reach SimpleRangeValue.onlyValues, which reads data[0].length and + // would throw. Mirrors UNIQUE/FILTER's empty-range handling. + if (data.length === 0 || data[0].length === 0) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyRange) + } + + const index = Math.trunc(sortIndex) + const sortDimension = byCol ? height : width + if (index < 1) { + return new CellError(ErrorType.VALUE, ErrorMessage.LessThanOne) + } + if (index > sortDimension) { + return new CellError(ErrorType.VALUE, ErrorMessage.ValueLarge) + } + + const keyIndex = index - 1 + const compare = (a: InternalNoErrorScalarValue, b: InternalNoErrorScalarValue): number => { + // Excel keeps empty cells at the end of the result regardless of + // sort_order, rather than coercing them to 0 and ordering by value. + // Force empties last (direction-independent) before delegating the + // rest to ArithmeticHelper. + const aEmpty = a === EmptyValue + const bEmpty = b === EmptyValue + if (aEmpty || bEmpty) { + if (aEmpty && bEmpty) { + return 0 + } + return aEmpty ? 1 : -1 + } + if (this.arithmeticHelper.lt(a, b)) { + return -sortOrder + } + if (this.arithmeticHelper.gt(a, b)) { + return sortOrder + } + return 0 + } + + if (byCol) { + // Reorder columns; the key of column c is data[keyIndex][c]. + const order = Array.from({length: width}, (_, c) => c) + order.sort((c1, c2) => compare( + data[keyIndex][c1] as InternalNoErrorScalarValue, + data[keyIndex][c2] as InternalNoErrorScalarValue, + )) + const result: InternalScalarValue[][] = data.map(row => order.map(c => row[c])) + return SimpleRangeValue.onlyValues(result) + } + + // Reorder rows; the key of row r is data[r][keyIndex]. + const rows: InternalScalarValue[][] = data.map(row => row.slice()) + rows.sort((r1, r2) => compare( + r1[keyIndex] as InternalNoErrorScalarValue, + r2[keyIndex] as InternalNoErrorScalarValue, + )) + return SimpleRangeValue.onlyValues(rows) + } + ) + } + + /** + * Predicts the output array size for SORT at parse time. + * The result is always the same shape as the input array. + * + * @param {ProcedureAst} ast - the parsed function-call AST node. + * @param {InterpreterState} state - current interpreter evaluation state. + */ + public sortArraySize(ast: ProcedureAst, state: InterpreterState): ArraySize { + if (ast.args.length < 1 || ast.args.length > 4) { + return ArraySize.error() + } + const metadata = this.metadata('SORT') + const subChecks = ast.args.map((arg) => + this.arraySizeForAst(arg, new InterpreterState(state.formulaAddress, state.arraysFlag || (metadata?.enableArrayArithmeticForArguments ?? false)))) + // Return a fresh ArraySize (isRef defaults to false). Propagating the input's + // ArraySize verbatim would carry its `isRef` flag, and ArraySize.isScalar() + // treats a ref as scalar — which would collapse the spilled result into a + // single cell. SORT's output always matches the input shape. + 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 + } +} diff --git a/src/interpreter/plugin/index.ts b/src/interpreter/plugin/index.ts index e86ba4f2a..a390ad843 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 {SortPlugin} from './SortPlugin' export {RoundingPlugin} from './RoundingPlugin' export {SqrtPlugin} from './SqrtPlugin' export {ConditionalAggregationPlugin} from './ConditionalAggregationPlugin'