Skip to content

Add calculation cache to improve CalcCellValue performance #2144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
32 changes: 32 additions & 0 deletions calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,14 @@ type formulaFuncs struct {
// Z.TEST
// ZTEST
func (f *File) CalcCellValue(sheet, cell string, opts ...Options) (result string, err error) {
cacheKey := fmt.Sprintf("%s!%s", sheet, cell)
f.calcCacheMu.RLock()
if cachedResult, found := f.calcCache.Load(cacheKey); found {
f.calcCacheMu.RUnlock()
return cachedResult.(string), nil
}
f.calcCacheMu.RUnlock()

options := f.getOptions(opts...)
var (
rawCellValue = options.RawCellValue
Expand All @@ -836,14 +844,29 @@ func (f *File) CalcCellValue(sheet, cell string, opts ...Options) (result string
_, precision, decimal := isNumeric(token.Value())
if precision > 15 {
result, err = f.formattedValue(&xlsxC{S: styleIdx, V: strings.ToUpper(strconv.FormatFloat(decimal, 'G', 15, 64))}, rawCellValue, CellTypeNumber)
if err == nil {
f.calcCacheMu.Lock()
f.calcCache.Store(cacheKey, result)
f.calcCacheMu.Unlock()
}
return
}
if !strings.HasPrefix(result, "0") {
result, err = f.formattedValue(&xlsxC{S: styleIdx, V: strings.ToUpper(strconv.FormatFloat(decimal, 'f', -1, 64))}, rawCellValue, CellTypeNumber)
}
if err == nil {
f.calcCacheMu.Lock()
f.calcCache.Store(cacheKey, result)
f.calcCacheMu.Unlock()
}
return
}
result, err = f.formattedValue(&xlsxC{S: styleIdx, V: token.Value()}, rawCellValue, CellTypeInlineString)
if err == nil {
f.calcCacheMu.Lock()
f.calcCache.Store(cacheKey, result)
f.calcCacheMu.Unlock()
}
return
}

Expand Down Expand Up @@ -18842,3 +18865,12 @@ func (fn *formulaFuncs) DISPIMG(argsList *list.List) formulaArg {
}
return argsList.Front().Value.(formulaArg)
}

func (f *File) clearCalcCache() {
f.calcCacheMu.Lock()
defer f.calcCacheMu.Unlock()
f.calcCache.Range(func(key, value interface{}) bool {
f.calcCache.Delete(key)
return true
})
}
108 changes: 108 additions & 0 deletions calc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6502,3 +6502,111 @@ func TestParseToken(t *testing.T) {
efp.Token{TSubType: efp.TokenSubTypeRange, TValue: "1A"}, nil, nil,
).Error())
}

// TestCalcCellValueCache tests the calculation cache functionality
func TestCalcCellValueCache(t *testing.T) {
f := NewFile()

assert.NoError(t, f.SetCellValue("Sheet1", "A1", 40))
assert.NoError(t, f.SetCellValue("Sheet1", "A2", 50))
assert.NoError(t, f.SetCellFormula("Sheet1", "A3", "A1+A2"))

result1, err := f.CalcCellValue("Sheet1", "A3")
assert.NoError(t, err)
assert.Equal(t, "90", result1)

result2, err := f.CalcCellValue("Sheet1", "A3")
assert.NoError(t, err)
assert.Equal(t, result1, result2, "cached result should be consistent")

assert.NoError(t, f.SetCellValue("Sheet1", "A1", 60))

result3, err := f.CalcCellValue("Sheet1", "A3")
assert.NoError(t, err)
assert.Equal(t, "110", result3)
assert.NotEqual(t, result1, result3, "result should be updated after cache clear")
}

// TestCalcCacheMultipleCells tests cache functionality with multiple dependent cells
func TestCalcCacheMultipleCells(t *testing.T) {
f := NewFile()

assert.NoError(t, f.SetCellValue("Sheet1", "A1", 10))
assert.NoError(t, f.SetCellValue("Sheet1", "A2", 10))
assert.NoError(t, f.SetCellFormula("Sheet1", "A3", "A1+A2"))
assert.NoError(t, f.SetCellFormula("Sheet1", "A4", "A3*3"))
assert.NoError(t, f.SetCellFormula("Sheet1", "A5", "A3+A4"))

result3, err := f.CalcCellValue("Sheet1", "A3")
assert.NoError(t, err)
assert.Equal(t, "20", result3)

result4, err := f.CalcCellValue("Sheet1", "A4")
assert.NoError(t, err)
assert.Equal(t, "60", result4)

result5, err := f.CalcCellValue("Sheet1", "A5")
assert.NoError(t, err)
assert.Equal(t, "80", result5)

assert.NoError(t, f.SetCellValue("Sheet1", "A1", 20))

newResult3, err := f.CalcCellValue("Sheet1", "A3")
assert.NoError(t, err)
assert.Equal(t, "30", newResult3)
assert.NotEqual(t, result3, newResult3, "A3 should be updated")

newResult5, err := f.CalcCellValue("Sheet1", "A5")
assert.NoError(t, err)
assert.Equal(t, "120", newResult5)
assert.NotEqual(t, result5, newResult5, "A5 should be updated")
}

// TestSetFunctionsClearCache tests that all Set functions properly clear the cache
func TestSetFunctionsClearCache(t *testing.T) {
f := NewFile()

assert.NoError(t, f.SetCellValue("Sheet1", "A1", 10))
assert.NoError(t, f.SetCellFormula("Sheet1", "A2", "A1*2"))

result1, err := f.CalcCellValue("Sheet1", "A2")
assert.NoError(t, err)
assert.Equal(t, "20", result1)

result2, err := f.CalcCellValue("Sheet1", "A2")
assert.NoError(t, err)
assert.Equal(t, result1, result2, "results should be consistent from cache")

testCases := []struct {
name string
setFunc func() error
}{
{"SetCellValue", func() error { return f.SetCellValue("Sheet1", "B1", 100) }},
{"SetCellInt", func() error { return f.SetCellInt("Sheet1", "B2", 200) }},
{"SetCellUint", func() error { return f.SetCellUint("Sheet1", "B3", 300) }},
{"SetCellFloat", func() error { return f.SetCellFloat("Sheet1", "B4", 3.14, 2, 64) }},
{"SetCellStr", func() error { return f.SetCellStr("Sheet1", "B5", "test") }},
{"SetCellBool", func() error { return f.SetCellBool("Sheet1", "B6", true) }},
{"SetCellDefault", func() error { return f.SetCellDefault("Sheet1", "B7", "default") }},
{"SetCellFormula", func() error { return f.SetCellFormula("Sheet1", "B8", "=1+1") }},
{"SetCellHyperLink", func() error { return f.SetCellHyperLink("Sheet1", "B9", "http://example.com", "External") }},
{"SetCellRichText", func() error {
runs := []RichTextRun{{Text: "Rich", Font: &Font{Bold: true}}}
return f.SetCellRichText("Sheet1", "B10", runs)
}},
{"SetSheetRow", func() error { return f.SetSheetRow("Sheet1", "C1", &[]interface{}{1, 2, 3}) }},
{"SetSheetCol", func() error { return f.SetSheetCol("Sheet1", "D1", &[]interface{}{4, 5, 6}) }},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Ensure cache is built
_, err := f.CalcCellValue("Sheet1", "A2")
assert.NoError(t, err)
assert.NoError(t, tc.setFunc())
result, err := f.CalcCellValue("Sheet1", "A2")
assert.NoError(t, err)
assert.Equal(t, "20", result, "calculation should still work after cache clear")
})
}
}
12 changes: 12 additions & 0 deletions cell.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (f *File) GetCellType(sheet, cell string) (CellType, error) {
// the cell value as number 0 or 60, then create and bind the date-time number
// format style for the cell.
func (f *File) SetCellValue(sheet, cell string, value interface{}) error {
f.clearCalcCache()
var err error
switch v := value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
Expand Down Expand Up @@ -292,6 +293,7 @@ func setCellDuration(value time.Duration) (t string, v string) {
// SetCellInt provides a function to set int type value of a cell by given
// worksheet name, cell reference and cell value.
func (f *File) SetCellInt(sheet, cell string, value int64) error {
f.clearCalcCache()
f.mu.Lock()
ws, err := f.workSheetReader(sheet)
if err != nil {
Expand Down Expand Up @@ -320,6 +322,7 @@ func setCellInt(value int64) (t string, v string) {
// SetCellUint provides a function to set uint type value of a cell by given
// worksheet name, cell reference and cell value.
func (f *File) SetCellUint(sheet, cell string, value uint64) error {
f.clearCalcCache()
f.mu.Lock()
ws, err := f.workSheetReader(sheet)
if err != nil {
Expand Down Expand Up @@ -349,6 +352,7 @@ func setCellUint(value uint64) (t string, v string) {
// SetCellBool provides a function to set bool type value of a cell by given
// worksheet name, cell reference and cell value.
func (f *File) SetCellBool(sheet, cell string, value bool) error {
f.clearCalcCache()
f.mu.Lock()
ws, err := f.workSheetReader(sheet)
if err != nil {
Expand Down Expand Up @@ -389,6 +393,7 @@ func setCellBool(value bool) (t string, v string) {
// var x float32 = 1.325
// f.SetCellFloat("Sheet1", "A1", float64(x), 2, 32)
func (f *File) SetCellFloat(sheet, cell string, value float64, precision, bitSize int) error {
f.clearCalcCache()
if math.IsNaN(value) || math.IsInf(value, 0) {
return f.SetCellStr(sheet, cell, fmt.Sprint(value))
}
Expand Down Expand Up @@ -424,6 +429,7 @@ func (c *xlsxC) setCellFloat(value float64, precision, bitSize int) {
// SetCellStr provides a function to set string type value of a cell. Total
// number of characters that a cell can contain 32767 characters.
func (f *File) SetCellStr(sheet, cell, value string) error {
f.clearCalcCache()
f.mu.Lock()
ws, err := f.workSheetReader(sheet)
if err != nil {
Expand Down Expand Up @@ -650,6 +656,7 @@ func (c *xlsxC) getValueFrom(f *File, d *xlsxSST, raw bool) (string, error) {
// SetCellDefault provides a function to set string type value of a cell as
// default format without escaping the cell.
func (f *File) SetCellDefault(sheet, cell, value string) error {
f.clearCalcCache()
f.mu.Lock()
ws, err := f.workSheetReader(sheet)
if err != nil {
Expand Down Expand Up @@ -787,6 +794,7 @@ type FormulaOpts struct {
// }
// }
func (f *File) SetCellFormula(sheet, cell, formula string, opts ...FormulaOpts) error {
f.clearCalcCache()
ws, err := f.workSheetReader(sheet)
if err != nil {
return err
Expand Down Expand Up @@ -1044,6 +1052,7 @@ func (f *File) removeHyperLink(ws *xlsxWorksheet, sheet, cell string) error {
//
// err := f.SetCellHyperLink("Sheet1", "A3", "Sheet1!A40", "Location")
func (f *File) SetCellHyperLink(sheet, cell, link, linkType string, opts ...HyperlinkOpts) error {
f.clearCalcCache()
// Check for correct cell name
if _, _, err := SplitCellName(cell); err != nil {
return err
Expand Down Expand Up @@ -1350,6 +1359,7 @@ func setRichText(runs []RichTextRun) ([]xlsxR, error) {
// }
// }
func (f *File) SetCellRichText(sheet, cell string, runs []RichTextRun) error {
f.clearCalcCache()
ws, err := f.workSheetReader(sheet)
if err != nil {
return err
Expand Down Expand Up @@ -1390,6 +1400,7 @@ func (f *File) SetCellRichText(sheet, cell string, runs []RichTextRun) error {
//
// err := f.SetSheetRow("Sheet1", "B6", &[]interface{}{"1", nil, 2})
func (f *File) SetSheetRow(sheet, cell string, slice interface{}) error {
f.clearCalcCache()
return f.setSheetCells(sheet, cell, slice, rows)
}

Expand All @@ -1399,6 +1410,7 @@ func (f *File) SetSheetRow(sheet, cell string, slice interface{}) error {
//
// err := f.SetSheetCol("Sheet1", "B6", &[]interface{}{"1", nil, 2})
func (f *File) SetSheetCol(sheet, cell string, slice interface{}) error {
f.clearCalcCache()
return f.setSheetCells(sheet, cell, slice, columns)
}

Expand Down
2 changes: 2 additions & 0 deletions col.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ func (f *File) GetColWidth(sheet, col string) (float64, error) {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) InsertCols(sheet, col string, n int) error {
f.clearCalcCache()
num, err := ColumnNameToNumber(col)
if err != nil {
return err
Expand All @@ -768,6 +769,7 @@ func (f *File) InsertCols(sheet, col string, n int) error {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) RemoveCol(sheet, col string) error {
f.clearCalcCache()
num, err := ColumnNameToNumber(col)
if err != nil {
return err
Expand Down
2 changes: 2 additions & 0 deletions excelize.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type File struct {
streams map[string]*StreamWriter
tempFiles sync.Map
xmlAttr sync.Map
calcCache sync.Map
calcCacheMu sync.RWMutex
CalcChain *xlsxCalcChain
CharsetReader charsetTranscoderFn
Comments map[string]*xlsxComments
Expand Down
2 changes: 2 additions & 0 deletions merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (mc *xlsxMergeCell) Rect() ([]int, error) {
// |A8(x3,y4) C8(x4,y4)|
// +------------------------+
func (f *File) MergeCell(sheet, topLeftCell, bottomRightCell string) error {
f.clearCalcCache()
rect, err := rangeRefToCoordinates(topLeftCell + ":" + bottomRightCell)
if err != nil {
return err
Expand Down Expand Up @@ -94,6 +95,7 @@ func (f *File) MergeCell(sheet, topLeftCell, bottomRightCell string) error {
//
// Attention: overlapped range will also be unmerged.
func (f *File) UnmergeCell(sheet, topLeftCell, bottomRightCell string) error {
f.clearCalcCache()
ws, err := f.workSheetReader(sheet)
if err != nil {
return err
Expand Down
4 changes: 4 additions & 0 deletions rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ func (f *File) GetRowOutlineLevel(sheet string, row int) (uint8, error) {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) RemoveRow(sheet string, row int) error {
f.clearCalcCache()
if row < 1 {
return newInvalidRowNumberError(row)
}
Expand Down Expand Up @@ -676,6 +677,7 @@ func (f *File) RemoveRow(sheet string, row int) error {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) InsertRows(sheet string, row, n int) error {
f.clearCalcCache()
if row < 1 {
return newInvalidRowNumberError(row)
}
Expand All @@ -697,6 +699,7 @@ func (f *File) InsertRows(sheet string, row, n int) error {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) DuplicateRow(sheet string, row int) error {
f.clearCalcCache()
return f.DuplicateRowTo(sheet, row, row+1)
}

Expand All @@ -710,6 +713,7 @@ func (f *File) DuplicateRow(sheet string, row int) error {
// worksheet, it will cause a file error when you open it. The excelize only
// partially updates these references currently.
func (f *File) DuplicateRowTo(sheet string, row, row2 int) error {
f.clearCalcCache()
if row < 1 {
return newInvalidRowNumberError(row)
}
Expand Down
4 changes: 4 additions & 0 deletions sheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ func (f *File) getActiveSheetID() int {
// sheet name in the formula or reference associated with the cell. So there
// may be problem formula error or reference missing.
func (f *File) SetSheetName(source, target string) error {
f.clearCalcCache()
var err error
if err = checkSheetName(source); err != nil {
return err
Expand Down Expand Up @@ -572,6 +573,7 @@ func (f *File) setSheetBackground(sheet, extension string, file []byte) error {
// value of the deleted worksheet, it will cause a file error when you open
// it. This function will be invalid when only one worksheet is left.
func (f *File) DeleteSheet(sheet string) error {
f.clearCalcCache()
if err := checkSheetName(sheet); err != nil {
return err
}
Expand Down Expand Up @@ -626,6 +628,7 @@ func (f *File) DeleteSheet(sheet string) error {
//
// err := f.MoveSheet("Sheet2", "Sheet1")
func (f *File) MoveSheet(source, target string) error {
f.clearCalcCache()
if strings.EqualFold(source, target) {
return nil
}
Expand Down Expand Up @@ -753,6 +756,7 @@ func (f *File) getSheetRelationshipsTargetByID(sheet, rID string) string {
// }
// err := f.CopySheet(1, index)
func (f *File) CopySheet(from, to int) error {
f.clearCalcCache()
if from < 0 || to < 0 || from == to || f.GetSheetName(from) == "" || f.GetSheetName(to) == "" {
return ErrSheetIdx
}
Expand Down