Skip to content
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

Support table referencing with columns #2063

Open
wants to merge 9 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
63 changes: 63 additions & 0 deletions calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,13 @@ const (
tfmmss = `(([0-9])+):(([0-9])+\.([0-9])+)( (am|pm))?`
tfhhmmss = `(([0-9])+):(([0-9])+):(([0-9])+(\.([0-9])+)?)( (am|pm))?`
timeSuffix = `( (` + tfhh + `|` + tfhhmm + `|` + tfmmss + `|` + tfhhmmss + `))?$`

tableRefPartsCnt = 3
)

var (
errNotExistingTable = errors.New("not existing table")
errNotExistingColumn = errors.New("not existing column")
// tokenPriority defined basic arithmetic operator priority
tokenPriority = map[string]int{
"^": 5,
Expand Down Expand Up @@ -211,6 +215,7 @@ var (
criteriaL,
criteriaG,
}
tableRefRe = regexp.MustCompile(`^(\w+)\[([^\]]+)\]$`)
)

// calcContext defines the formula execution context.
Expand Down Expand Up @@ -1494,6 +1499,7 @@ func parseRef(ref string) (cellRef, bool, bool, error) {
cell = ref
tokens = strings.Split(ref, "!")
)

if len(tokens) == 2 { // have a worksheet
cr.Sheet, cell = tokens[0], tokens[1]
}
Expand All @@ -1509,6 +1515,58 @@ func parseRef(ref string) (cellRef, bool, bool, error) {
return cr, false, false, err
}

func pickColumnInTableRef(tblRef tableRef, colName string) (string, error) {
offset := -1

// Column ID is not reliable for order so we need to iterate through them.
for i, otherColName := range tblRef.columns {
if colName == otherColName {
offset = i
}
}

if offset == -1 {
return "", fmt.Errorf("column `%s` not in table: %w", colName, errNotExistingColumn)
}

// Tables having just a single cell are invalid. Hence it is safe to assume it should always be a range reference.
coords, err := rangeRefToCoordinates(tblRef.ref)
if err != nil {
return "", err
}

col := coords[0] + offset
rangeRef, err := coordinatesToRangeRef([]int{col, coords[1] + 1, col, coords[3]})
if err != nil {
return "", err
}

return fmt.Sprintf("%s!%s", tblRef.sheet, rangeRef), nil
}

func (f *File) tryParseAsTableRef(ref string) (string, error) {
submatch := tableRefRe.FindStringSubmatch(ref)
// Fallback to regular ref.
if len(submatch) != tableRefPartsCnt {
return ref, nil
}

tableName := submatch[1]
colName := submatch[2]

rawTblRef, ok := f.tableRefs.Load(tableName)
if !ok {
return "", fmt.Errorf("referencing table `%s`: %w", tableName, errNotExistingTable)
}

tblRef, ok := rawTblRef.(tableRef)
if !ok {
panic(fmt.Sprintf("unexpected reference type %T", ref))
}

return pickColumnInTableRef(tblRef, colName)
}

// prepareCellRange checking and convert cell reference to a cell range.
func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error {
if col {
Expand Down Expand Up @@ -1542,6 +1600,11 @@ func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error {
// characters and default sheet name.
func (f *File) parseReference(ctx *calcContext, sheet, reference string) (formulaArg, error) {
reference = strings.ReplaceAll(reference, "$", "")
reference, err := f.tryParseAsTableRef(reference)
if err != nil {
return newErrorFormulaArg(formulaErrorNAME, "invalid reference"), err
}

ranges, cellRanges, cellRefs := strings.Split(reference, ":"), list.New(), list.New()
if len(ranges) > 1 {
var cr cellRange
Expand Down
79 changes: 79 additions & 0 deletions calc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6447,6 +6447,85 @@ func TestCalcCellResolver(t *testing.T) {
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
}

func TestTableReference(t *testing.T) {
f := sheetWithTables(t)

assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")
assert.NoError(t, f.SetCellFormula("Sheet1", "B1", "=INDEX(FieryTable[Column2], 1)"), "cell formula for A2")
assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "=B1*2"), "cell formula for A3")
assert.NoError(t, f.SetCellFormula("Sheet1", "D1", "=INDEX(FrostyTable[Column1], 1)"), "cell formula for A1")

res, err := f.CalcCellValue("Sheet1", "A1")
assert.NoError(t, err, "calculating cell A1")
assert.Equal(t, "Foo", res, "A1 calc is wrong")

res, err = f.CalcCellValue("Sheet1", "B1")
assert.NoError(t, err, "calculating cell B1")
assert.Equal(t, "12.5", res, "B1 calc is wrong")

res, err = f.CalcCellValue("Sheet1", "C1")
assert.NoError(t, err, "calculating cell C1")
assert.Equal(t, "25", res, "C1 calc is wrong")

res, err = f.CalcCellValue("Sheet1", "D1")
assert.NoError(t, err, "calculating cell D1")
assert.Equal(t, "Hedgehog", res, "D1 calc is wrong")
}

func TestTableRefenceFromOtherSheet(t *testing.T) {
f := sheetWithTables(t)

_, err := f.NewSheet("Sheet2")
assert.NoError(t, err, "creating Sheet2")

assert.NoError(t, f.SetCellFormula("Sheet2", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")

res, err := f.CalcCellValue("Sheet2", "A1")
assert.NoError(t, err, "calculating cell A1")
assert.Equal(t, "Foo", res, "A1 calc is wrong")
}

func TestTableReferenceWithDeletedTable(t *testing.T) {
f := sheetWithTables(t)

assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")
assert.NoError(t, f.DeleteTable("FieryTable"), "deleting table")

_, err := f.CalcCellValue("Sheet1", "A1")
assert.Error(t, err, "A1 calc is wrong")
}

func TestTableReferenceToNotExistingTable(t *testing.T) {
f := sheetWithTables(t)
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(NotExisting[Column1], 1)"), "cell formula for A1")

_, err := f.CalcCellValue("Sheet1", "A1")
assert.Error(t, err, "A1 calc is wrong")
}

func TestTableReferenceToNotExistingColumn(t *testing.T) {
f := sheetWithTables(t)
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[NotExisting], 1)"), "cell formula for A1")

_, err := f.CalcCellValue("Sheet1", "A1")
assert.Error(t, err, "A1 calc is wrong")
}

func sheetWithTables(t *testing.T) *File {
f := NewFile()

// Multi column with default column names
assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A2:C5", Name: "FieryTable"}), "adding FieryTable")
assert.NoError(t, f.SetCellValue("Sheet1", "A3", "Foo"), "set A3")
assert.NoError(t, f.SetCellValue("Sheet1", "B3", "12.5"), "set A3")

// Single column with renamed column
assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A8:A9", Name: "FrostyTable"}), "adding FrostyTable")
assert.NoError(t, f.SetCellValue("Sheet1", "A9", "Hedgehog"), "set A3")

return f
}

func TestEvalInfixExp(t *testing.T) {
f := NewFile()
arg, err := f.evalInfixExp(nil, "Sheet1", "A1", []efp.Token{
Expand Down
153 changes: 153 additions & 0 deletions excelize.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
Expand All @@ -26,6 +28,8 @@ import (
"golang.org/x/net/html/charset"
)

const targetModeExternal = "external"

// File define a populated spreadsheet file struct.
type File struct {
mu sync.Mutex
Expand All @@ -39,6 +43,7 @@ type File struct {
streams map[string]*StreamWriter
tempFiles sync.Map
xmlAttr sync.Map
tableRefs sync.Map
CalcChain *xlsxCalcChain
CharsetReader charsetTranscoderFn
Comments map[string]*xlsxComments
Expand All @@ -59,6 +64,19 @@ type File struct {
WorkBook *xlsxWorkbook
}

type tableRef struct {
ref string
sheet string
columns []string
}

type relationMetadata struct {
wb *xlsxWorkbook
wbRels *xlsxRelationships
relsPerSheet map[string]*xlsxRelationships
tables map[string]*xlsxTable
}

// charsetTranscoderFn set user-defined codepage transcoder function for open
// the spreadsheet from non-UTF-8 encoding.
type charsetTranscoderFn func(charset string, input io.Reader) (rdr io.Reader, err error)
Expand Down Expand Up @@ -140,6 +158,7 @@ func newFile() *File {
checked: sync.Map{},
sheetMap: make(map[string]string),
tempFiles: sync.Map{},
tableRefs: sync.Map{},
Comments: make(map[string]*xlsxComments),
Drawings: sync.Map{},
sharedStringsMap: make(map[string]int),
Expand Down Expand Up @@ -204,6 +223,10 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) {
for k, v := range file {
f.Pkg.Store(k, v)
}

if err := f.storeRelations(file); err != nil {
return f, err
}
if f.CalcChain, err = f.calcChainReader(); err != nil {
return f, err
}
Expand All @@ -217,6 +240,136 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) {
return f, err
}

func (f *File) storeRelations(files map[string][]byte) error {
relMetadata, err := f.parseRelationMetadata(files)
if err != nil {
return err
}
if relMetadata.wb == nil || relMetadata.wbRels == nil {
return nil
}

sheetRelIDs := make(map[string]string)
for _, sheet := range relMetadata.wb.Sheets.Sheet {
sheetRelIDs[sheet.ID] = sheet.Name
}

sheetBaseToSheetNames := make(map[string]string)
for _, rel := range relMetadata.wbRels.Relationships {
sheetName, ok := sheetRelIDs[rel.ID]

if !ok || strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipWorkSheet {
continue
}

sheetBaseToSheetNames[fmt.Sprintf("%s.rels", path.Base(rel.Target))] = sheetName
}

tableBaseToSheetNames := make(map[string]string)
for key, sheetRels := range relMetadata.relsPerSheet {
sheetName, ok := sheetBaseToSheetNames[key]
if !ok {
continue
}

for _, rel := range sheetRels.Relationships {
if strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipTable {
continue
}

tableBaseToSheetNames[path.Base(rel.Target)] = sheetName
}
}

for key, t := range relMetadata.tables {
if sheetName, ok := tableBaseToSheetNames[key]; ok {
f.tableRefs.Store(t.Name, tableRefFromXLSXTable(t, sheetName))
}
}

return nil
}

func (f *File) parseRelationMetadata(files map[string][]byte) (*relationMetadata, error) {
var err error
relMetadata := &relationMetadata{
relsPerSheet: map[string]*xlsxRelationships{},
tables: map[string]*xlsxTable{},
}

for k, v := range files {
switch {
case strings.Contains(k, "xl/workbook.xml") && v != nil:
relMetadata.wb, err = f.parseWorkbook(v)
if err != nil {
return nil, err
}
case strings.Contains(k, "xl/_rels/workbook.xml.rels") && v != nil:
relMetadata.wbRels, err = f.parseRelationships(v)
if err != nil {
return nil, fmt.Errorf("workbook rels: %w", err)
}
case strings.Contains(k, "xl/worksheets/_rels") && v != nil:
sheetRels, err := f.parseRelationships(v)
if err != nil {
return nil, fmt.Errorf("workbook sheet rel %s: %w", k, err)
}
relMetadata.relsPerSheet[path.Base(k)] = sheetRels
case strings.Contains(k, "xl/tables") && v != nil:
table, err := f.parseTable(v)
if err != nil {
return nil, fmt.Errorf("table %s: %w", k, err)
}
relMetadata.tables[path.Base(k)] = table
}
}

return relMetadata, nil
}

func (f *File) parseWorkbook(v []byte) (*xlsxWorkbook, error) {
var wb *xlsxWorkbook

dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
if err := dec.Decode(&wb); err != nil && err != io.EOF {
return nil, fmt.Errorf("decoding workbook: %w", err)
}

return wb, nil
}

func (f *File) parseRelationships(v []byte) (*xlsxRelationships, error) {
var rels *xlsxRelationships

dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
if err := dec.Decode(&rels); err != nil && err != io.EOF {
return nil, fmt.Errorf("decoding relationships: %w", err)
}

return rels, nil
}

func (f *File) parseTable(v []byte) (*xlsxTable, error) {
var table *xlsxTable
dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
if err := dec.Decode(&table); err != nil && err != io.EOF {
return nil, fmt.Errorf("parsing table: %w", err)
}
return table, nil
}

func tableRefFromXLSXTable(t *xlsxTable, sheet string) tableRef {
tblRef := tableRef{
ref: t.Ref,
sheet: sheet,
columns: make([]string, 0, t.TableColumns.Count),
}
for _, col := range t.TableColumns.TableColumn {
tblRef.columns = append(tblRef.columns, col.Name)
}
return tblRef
}

// getOptions provides a function to parse the optional settings for open
// and reading spreadsheet.
func (f *File) getOptions(opts ...Options) *Options {
Expand Down
2 changes: 1 addition & 1 deletion excelize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ func TestOpenReader(t *testing.T) {
defaultXMLPathWorkbookRels,
} {
_, err = OpenReader(preset(defaultXMLPath, false))
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
assert.ErrorContains(t, err, "XML syntax error on line 1: invalid UTF-8")
}
// Test open workbook without internal XML parts
for _, defaultXMLPath := range []string{
Expand Down
Loading