diff --git a/calc.go b/calc.go index 96bfdf11d7..3b6e73b29d 100644 --- a/calc.go +++ b/calc.go @@ -757,6 +757,7 @@ type formulaFuncs struct { // SLN // SLOPE // SMALL +// SORTBY // SQRT // SQRTPI // STANDARDIZE @@ -19219,3 +19220,176 @@ func (fn *formulaFuncs) DISPIMG(argsList *list.List) formulaArg { } return argsList.Front().Value.(formulaArg) } + +// sortbyArgs holds the parsed arguments for the SORTBY function. +type sortbyArgs struct { + array []formulaArg + cols int + rows int + sortKeys []sortbyKey +} + +// sortbyKey represents a single sort key with its associated array and order. +type sortbyKey struct { + byArray []formulaArg + cols int + rows int + ascending bool // true = ascending (1 or omitted), false = descending (-1) +} + +// rowWithKeys pairs a data row with its corresponding sort key values. +type rowWithKeys struct { + rowData []formulaArg + sortKeys [][]formulaArg // up to 3 sets of sort keys +} + +// SORTBY function sorts the contents of a range or array based on the values +// in a corresponding range or array. The syntax of the function is: +// +// SORTBY(array,by_array1,[sort_order1],[by_array2,sort_order2],[by_array3, sort_order3]) +func (fn *formulaFuncs) SORTBY(argsList *list.List) formulaArg { + args, errArg := prepareSortbyArgs(argsList) + if errArg != nil { + return *errArg + } + rowsWithKeys := make([]rowWithKeys, args.rows) + for i := 0; i < args.rows; i++ { + rowsWithKeys[i].rowData = args.array[i*args.cols : (i+1)*args.cols] + rowsWithKeys[i].sortKeys = make([][]formulaArg, len(args.sortKeys)) + for keyIdx, sortKey := range args.sortKeys { + rowsWithKeys[i].sortKeys[keyIdx] = sortKey.byArray[i*sortKey.cols : (i+1)*sortKey.cols] + } + } + sort.Slice(rowsWithKeys, func(i, j int) bool { + return compareRowsForSortby(rowsWithKeys[i], rowsWithKeys[j], args.sortKeys) + }) + result := make([][]formulaArg, args.rows) + for i, row := range rowsWithKeys { + result[i] = row.rowData + } + return newMatrixFormulaArg(result) +} + +// checkSortbyArgs checking arguments for the formula function SORTBY. +func checkSortbyArgs(argsList *list.List) formulaArg { + argsLen := argsList.Len() + if argsLen < 2 { + return newErrorFormulaArg(formulaErrorVALUE, "SORTBY requires at least 2 arguments") + } + if argsLen > 7 { + return newErrorFormulaArg(formulaErrorVALUE, fmt.Sprintf("SORTBY takes at most 7 arguments, received %d", argsLen)) + } + if argsLen != 2 && argsLen != 3 && argsLen != 5 && argsLen != 7 { + return newErrorFormulaArg(formulaErrorVALUE, fmt.Sprintf("SORTBY requires 2, 3, 5, or 7 arguments, received %d", argsLen)) + } + arrArg := argsList.Front().Value.(formulaArg).ToList() + if len(arrArg) == 0 { + return newErrorFormulaArg(formulaErrorVALUE, "missing first argument to SORTBY") + } + if arrArg[0].Type == ArgError { + return arrArg[0] + } + return newListFormulaArg(arrArg) +} + +// prepareSortbyArgs prepare arguments for the formula function SORTBY. +func prepareSortbyArgs(argsList *list.List) (sortbyArgs, *formulaArg) { + res := sortbyArgs{} + args := checkSortbyArgs(argsList) + if args.Type != ArgList { + return res, &args + } + res.array = args.List + argsLen := argsList.Len() + array := argsList.Front() + tempList := list.New() + tempList.PushBack(array.Value) + rmin, rmax := calcColsRowsMinMax(false, tempList) + cmin, cmax := calcColsRowsMinMax(true, tempList) + res.cols, res.rows = cmax-cmin+1, rmax-rmin+1 + byArray := array.Next() + keyCount := 0 + for byArray != nil && keyCount < 3 { + var key sortbyKey + key.byArray = byArray.Value.(formulaArg).ToList() + if len(key.byArray) == 0 { + errArg := newErrorFormulaArg(formulaErrorVALUE, "missing by_array argument to SORTBY") + return res, &errArg + } + if key.byArray[0].Type == ArgError { + return res, &key.byArray[0] + } + tempList := list.New() + tempList.PushBack(byArray.Value) + rmin, rmax := calcColsRowsMinMax(false, tempList) + cmin, cmax := calcColsRowsMinMax(true, tempList) + key.cols, key.rows = cmax-cmin+1, rmax-rmin+1 + if key.rows != res.rows { + errArg := newErrorFormulaArg(formulaErrorVALUE, fmt.Sprintf("by_array dimensions (%d rows) do not match array dimensions (%d rows)", + key.rows, res.rows)) + return res, &errArg + } + key.ascending = true + nextByArray, errArg := parseSortOrderArg(byArray.Next(), &key, keyCount, argsLen) + if errArg != nil { + return res, errArg + } + byArray = nextByArray + res.sortKeys = append(res.sortKeys, key) + keyCount++ + } + return res, nil +} + +// parseSortOrderArg processes the optional sort_order argument for a SORTBY +// key. It updates the key's ascending field and returns the next element in the +// list. Returns an error if the sort_order value is invalid. +func parseSortOrderArg(byArray *list.Element, key *sortbyKey, keyCount, argsLen int) (*list.Element, *formulaArg) { + if byArray == nil { + return nil, nil + } + sortOrderArg := byArray.Value.(formulaArg).ToNumber() + expectedSortOrder := false + if keyCount == 0 && (argsLen == 3 || argsLen == 5 || argsLen == 7) { + expectedSortOrder = true + } else if keyCount == 1 && (argsLen == 5 || argsLen == 7) { + expectedSortOrder = true + } else if keyCount == 2 && argsLen == 7 { + expectedSortOrder = true + } + if sortOrderArg.Type == ArgError { + if expectedSortOrder { + return byArray, &sortOrderArg + } + return byArray, nil + } + switch sortOrderArg.Number { + case -1: + key.ascending = false + return byArray.Next(), nil + case 1: + key.ascending = true + return byArray.Next(), nil + } + errArg := newErrorFormulaArg(formulaErrorVALUE, fmt.Sprintf("sort_order must be 1 or -1, received %v", sortOrderArg.Number)) + return byArray, &errArg +} + +// compareRowsForSortby compares two rows using multiple sort keys. +// Returns true if row i should come before row j. +func compareRowsForSortby(i, j rowWithKeys, sortKeys []sortbyKey) bool { + for idx, sortKey := range sortKeys { + lhs, rhs := i.sortKeys[idx], j.sortKeys[idx] + for colIdx := 0; colIdx < len(lhs) && colIdx < len(rhs); colIdx++ { + criteria := compareFormulaArg(lhs[colIdx], rhs[colIdx], newNumberFormulaArg(matchModeMaxLess), false) + if criteria == criteriaEq { + continue + } + if sortKey.ascending { + return criteria == criteriaL + } + return criteria == criteriaG + } + } + return false +} diff --git a/calc_test.go b/calc_test.go index 3d29afbfec..45e6e52485 100644 --- a/calc_test.go +++ b/calc_test.go @@ -6802,6 +6802,126 @@ func TestCalcMatchMatrix(t *testing.T) { ) } +func TestCalcSORTBY(t *testing.T) { + cellData := [][]interface{}{ + {"Name", "Region", "Sales", "Quarter"}, + {"Alice", "North", 1500, 1}, + {"Bob", "South", 2000, 2}, + {"Alice", "North", 1200, 2}, + {"Charlie", "East", 1800, 1}, + {"Bob", "South", 2200, 1}, + {"David", "West", 1000, 2}, + {"Alice", "North", 1700, 3}, + {"Test", "Mixed", "Text", 4}, + {nil, "Empty", 500, 4}, + } + f := prepareCalcData(cellData) + formulaList := map[string]string{ + "TEXTJOIN(\",\", TRUE, SORTBY(A2:A8, C2:C8))": "David,Alice,Alice,Alice,Charlie,Bob,Bob", + "TEXTJOIN(\",\", TRUE, SORTBY(A2:A8, C2:C8, -1))": "Bob,Bob,Charlie,Alice,Alice,Alice,David", + "TEXTJOIN(\",\", TRUE, SORTBY(A2:A8, B2:B8, 1, C2:C8, -1))": "Charlie,Alice,Alice,Alice,Bob,Bob,David", + "TEXTJOIN(\",\", TRUE, SORTBY(A2:A8, D2:D8, 1, B2:B8, 1, C2:C8, -1))": "Charlie,Alice,Bob,Alice,Bob,David,Alice", + "TEXTJOIN(\";\", TRUE, SORTBY(A2:C4, C2:C4))": "Alice;North;1200;Alice;North;1500;Bob;South;2000", + "TEXTJOIN(\",\", TRUE, SORTBY(C2:C8, A2:B8))": "1500,1200,1700,2000,2200,1800,1000", + "TEXTJOIN(\",\", TRUE, SORTBY(A2:A10, C2:C10))": "David,Alice,Alice,Alice,Charlie,Bob,Bob,Test", + "TEXTJOIN(\",\", TRUE, SORTBY(D2:D8, D2:D8))": "1,1,1,2,2,2,3", + "TEXTJOIN(\",\", TRUE, SORTBY(B2:B8, B2:B8))": "East,North,North,North,South,South,West", + } + for formula, expected := range formulaList { + assert.NoError(t, f.SetCellFormula("Sheet1", "E1", formula)) + result, err := f.CalcCellValue("Sheet1", "E1") + assert.NoError(t, err, formula) + assert.Equal(t, expected, result, formula) + } + calcError := map[string][]string{ + "SORTBY(A2:A7)": {"#VALUE!", "SORTBY requires at least 2 arguments"}, + "SORTBY(A2:A7, C2:C7, 1, D2:D7, 1, B2:B7, 1, E2:E7, 1)": {"#VALUE!", "SORTBY takes at most 7 arguments, received 9"}, + "SORTBY(A2:A7, C2:C7, 1, D2:D7)": {"#VALUE!", "SORTBY requires 2, 3, 5, or 7 arguments, received 4"}, + "SORTBY(A2:A7, C2:C7, 1, D2:D7, 1, B2:B7)": {"#VALUE!", "SORTBY requires 2, 3, 5, or 7 arguments, received 6"}, + "SORTBY(A2:A7, C2:C5)": {"#VALUE!", "by_array dimensions (4 rows) do not match array dimensions (6 rows)"}, + "SORTBY(A2:A7, C2:C7, 2)": {"#VALUE!", "sort_order must be 1 or -1, received 2"}, + "SORTBY(A2:A7, C2:C7, 0)": {"#VALUE!", "sort_order must be 1 or -1, received 0"}, + } + for formula, expected := range calcError { + assert.NoError(t, f.SetCellFormula("Sheet1", "F1", formula)) + result, err := f.CalcCellValue("Sheet1", "F1") + assert.EqualError(t, err, expected[1], formula) + assert.Equal(t, expected[0], result, formula) + } + + f = prepareCalcData([][]interface{}{ + {"Name", "Score"}, + {"Alice", 100}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "TEXTJOIN(\",\", TRUE, SORTBY(A2:A2, B2:B2))")) + result, err := f.CalcCellValue("Sheet1", "C1") + assert.NoError(t, err) + assert.Equal(t, "Alice", result, "Single row should work") + + f = prepareCalcData([][]interface{}{ + {"Name", "Score"}, + {"Alice", 100}, + {"Bob", 100}, + {"Charlie", 100}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "TEXTJOIN(\",\", TRUE, SORTBY(A2:A4, B2:B4))")) + result, err = f.CalcCellValue("Sheet1", "C1") + assert.NoError(t, err) + assert.Equal(t, "Alice,Bob,Charlie", result, "Equal values maintain original order") + + f = prepareCalcData([][]interface{}{ + {"Name", "Group", "Score"}, + {"Charlie", "A", 90}, + {"Alice", "A", 95}, + {"Bob", "A", 85}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "D1", "TEXTJOIN(\",\", TRUE, SORTBY(A2:A4, B2:B4, 1, C2:C4, 1))")) + result, err = f.CalcCellValue("Sheet1", "D1") + assert.NoError(t, err) + assert.Equal(t, "Bob,Charlie,Alice", result, "Second key determines order when first is equal") + + f = prepareCalcData([][]interface{}{ + {"Name", "Score"}, + {"Alice", 100}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "=SORTBY(A2:A2,{})")) + result, err = f.CalcCellValue("Sheet1", "C1") + assert.Error(t, err) + assert.Equal(t, formulaErrorVALUE, result) + + f = prepareCalcData([][]interface{}{ + {"Name", "Score"}, + {"Alice", 100}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "=SORTBY({},B2:B2)")) + result, err = f.CalcCellValue("Sheet1", "C1") + assert.Error(t, err) + assert.Equal(t, formulaErrorVALUE, result) + + f = prepareCalcData([][]interface{}{ + {1, 2, 3}, + {4, 5, 6}, + {7, 8, 9}, + }) + assert.NoError(t, f.SetCellFormula("Sheet1", "D2", "=SORTBY(NA(),B1:B2)")) + result, err = f.CalcCellValue("Sheet1", "D2") + assert.Equal(t, formulaErrorNA, result) + assert.Equal(t, formulaErrorNA, err.Error()) + assert.NoError(t, f.SetCellFormula("Sheet1", "D4", "=SORTBY(A1:A2,NA())")) + _, err = f.CalcCellValue("Sheet1", "D4") + assert.Equal(t, formulaErrorNA, result) + assert.Equal(t, formulaErrorNA, err.Error()) + + argsList := list.New() + argsList.PushBack(newStringFormulaArg("text")) + nextByArray, errArg := parseSortOrderArg(argsList.Front(), &sortbyKey{}, 0, 3) + assert.Equal(t, "strconv.ParseFloat: parsing \"text\": invalid syntax", errArg.Error) + assert.NotNil(t, nextByArray) + nextByArray, errArg = parseSortOrderArg(argsList.Front(), &sortbyKey{}, 0, 1) + assert.Nil(t, errArg) + assert.NotNil(t, nextByArray) +} + func TestCalcTrendGrowthMultipleRegressionPart2(t *testing.T) { calcTrendGrowthMultipleRegressionPart2(true, false, [][]float64{{1}, {2}, {3}}, diff --git a/go.mod b/go.mod index bc8f1fe273..34d69cc6e6 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.24.0 require ( github.com/richardlehane/mscfb v1.0.4 github.com/stretchr/testify v1.11.1 - github.com/tiendc/go-deepcopy v1.7.1 + github.com/tiendc/go-deepcopy v1.7.2 github.com/xuri/efp v0.0.1 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.46.0 golang.org/x/image v0.25.0 - golang.org/x/net v0.47.0 - golang.org/x/text v0.31.0 + golang.org/x/net v0.48.0 + golang.org/x/text v0.32.0 ) require ( diff --git a/go.sum b/go.sum index 5bbf2d1202..02c6498a8a 100644 --- a/go.sum +++ b/go.sum @@ -9,20 +9,20 @@ github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= -github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=