Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ type formulaFuncs struct {
// SLN
// SLOPE
// SMALL
// SORTBY
// SQRT
// SQRTPI
// STANDARDIZE
Expand Down Expand Up @@ -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
}
120 changes: 120 additions & 0 deletions calc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down