Skip to content

Commit 6bc497c

Browse files
Support table refs (#1)
* Support table referencing * Make it work - was performing the parsing of refs incorrectly in the wrong place * Remove unnecessary log * Cover with tests and fix some corner cases * Follow conventions for placing actual vs expected * Fix returned range * Fix starting row * Handle cross sheet references of tables * Reference tableRefs directly from File struct --------- Co-authored-by: Ivan Hristov <ivan.hristov@abraxa.com>
1 parent caf22e4 commit 6bc497c

File tree

5 files changed

+298
-1
lines changed

5 files changed

+298
-1
lines changed

calc.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,13 @@ const (
9898
tfmmss = `(([0-9])+):(([0-9])+\.([0-9])+)( (am|pm))?`
9999
tfhhmmss = `(([0-9])+):(([0-9])+):(([0-9])+(\.([0-9])+)?)( (am|pm))?`
100100
timeSuffix = `( (` + tfhh + `|` + tfhhmm + `|` + tfmmss + `|` + tfhhmmss + `))?$`
101+
102+
tableRefPartsCnt = 3
101103
)
102104

103105
var (
106+
errNotExistingTable = errors.New("not existing table")
107+
errNotExistingColumn = errors.New("not existing column")
104108
// tokenPriority defined basic arithmetic operator priority
105109
tokenPriority = map[string]int{
106110
"^": 5,
@@ -211,6 +215,7 @@ var (
211215
criteriaL,
212216
criteriaG,
213217
}
218+
tableRefRe = regexp.MustCompile(`^(\w+)\[([^\]]+)\]$`)
214219
)
215220

216221
// calcContext defines the formula execution context.
@@ -1494,6 +1499,7 @@ func parseRef(ref string) (cellRef, bool, bool, error) {
14941499
cell = ref
14951500
tokens = strings.Split(ref, "!")
14961501
)
1502+
14971503
if len(tokens) == 2 { // have a worksheet
14981504
cr.Sheet, cell = tokens[0], tokens[1]
14991505
}
@@ -1509,6 +1515,58 @@ func parseRef(ref string) (cellRef, bool, bool, error) {
15091515
return cr, false, false, err
15101516
}
15111517

1518+
func pickColumnInTableRef(tblRef tableRef, colName string) (string, error) {
1519+
offset := -1
1520+
1521+
// Column ID is not reliable for order so we need to iterate through them.
1522+
for i, otherColName := range tblRef.columns {
1523+
if colName == otherColName {
1524+
offset = i
1525+
}
1526+
}
1527+
1528+
if offset == -1 {
1529+
return "", fmt.Errorf("column `%s` not in table: %w", colName, errNotExistingColumn)
1530+
}
1531+
1532+
// Tables having just a single cell are invalid. Hence it is safe to assume it should always be a range reference.
1533+
coords, err := rangeRefToCoordinates(tblRef.ref)
1534+
if err != nil {
1535+
return "", err
1536+
}
1537+
1538+
col := coords[0] + offset
1539+
rangeRef, err := coordinatesToRangeRef([]int{col, coords[1] + 1, col, coords[3]})
1540+
if err != nil {
1541+
return "", err
1542+
}
1543+
1544+
return fmt.Sprintf("%s!%s", tblRef.sheet, rangeRef), nil
1545+
}
1546+
1547+
func (f *File) tryParseAsTableRef(ref string) (string, error) {
1548+
submatch := tableRefRe.FindStringSubmatch(ref)
1549+
// Fallback to regular ref.
1550+
if len(submatch) != tableRefPartsCnt {
1551+
return ref, nil
1552+
}
1553+
1554+
tableName := submatch[1]
1555+
colName := submatch[2]
1556+
1557+
rawTblRef, ok := f.tableRefs.Load(tableName)
1558+
if !ok {
1559+
return "", fmt.Errorf("referencing table `%s`: %w", tableName, errNotExistingTable)
1560+
}
1561+
1562+
tblRef, ok := rawTblRef.(tableRef)
1563+
if !ok {
1564+
panic(fmt.Sprintf("unexpected reference type %T", ref))
1565+
}
1566+
1567+
return pickColumnInTableRef(tblRef, colName)
1568+
}
1569+
15121570
// prepareCellRange checking and convert cell reference to a cell range.
15131571
func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error {
15141572
if col {
@@ -1542,6 +1600,11 @@ func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error {
15421600
// characters and default sheet name.
15431601
func (f *File) parseReference(ctx *calcContext, sheet, reference string) (formulaArg, error) {
15441602
reference = strings.ReplaceAll(reference, "$", "")
1603+
reference, err := f.tryParseAsTableRef(reference)
1604+
if err != nil {
1605+
return newErrorFormulaArg(formulaErrorNAME, "invalid reference"), err
1606+
}
1607+
15451608
ranges, cellRanges, cellRefs := strings.Split(reference, ":"), list.New(), list.New()
15461609
if len(ranges) > 1 {
15471610
var cr cellRange

calc_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6447,6 +6447,85 @@ func TestCalcCellResolver(t *testing.T) {
64476447
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
64486448
}
64496449

6450+
func TestTableReference(t *testing.T) {
6451+
f := sheetWithTables(t)
6452+
6453+
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")
6454+
assert.NoError(t, f.SetCellFormula("Sheet1", "B1", "=INDEX(FieryTable[Column2], 1)"), "cell formula for A2")
6455+
assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "=B1*2"), "cell formula for A3")
6456+
assert.NoError(t, f.SetCellFormula("Sheet1", "D1", "=INDEX(FrostyTable[Column1], 1)"), "cell formula for A1")
6457+
6458+
res, err := f.CalcCellValue("Sheet1", "A1")
6459+
assert.NoError(t, err, "calculating cell A1")
6460+
assert.Equal(t, "Foo", res, "A1 calc is wrong")
6461+
6462+
res, err = f.CalcCellValue("Sheet1", "B1")
6463+
assert.NoError(t, err, "calculating cell B1")
6464+
assert.Equal(t, "12.5", res, "B1 calc is wrong")
6465+
6466+
res, err = f.CalcCellValue("Sheet1", "C1")
6467+
assert.NoError(t, err, "calculating cell C1")
6468+
assert.Equal(t, "25", res, "C1 calc is wrong")
6469+
6470+
res, err = f.CalcCellValue("Sheet1", "D1")
6471+
assert.NoError(t, err, "calculating cell D1")
6472+
assert.Equal(t, "Hedgehog", res, "D1 calc is wrong")
6473+
}
6474+
6475+
func TestTableRefenceFromOtherSheet(t *testing.T) {
6476+
f := sheetWithTables(t)
6477+
6478+
_, err := f.NewSheet("Sheet2")
6479+
assert.NoError(t, err, "creating Sheet2")
6480+
6481+
assert.NoError(t, f.SetCellFormula("Sheet2", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")
6482+
6483+
res, err := f.CalcCellValue("Sheet2", "A1")
6484+
assert.NoError(t, err, "calculating cell A1")
6485+
assert.Equal(t, "Foo", res, "A1 calc is wrong")
6486+
}
6487+
6488+
func TestTableReferenceWithDeletedTable(t *testing.T) {
6489+
f := sheetWithTables(t)
6490+
6491+
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1")
6492+
assert.NoError(t, f.DeleteTable("FieryTable"), "deleting table")
6493+
6494+
_, err := f.CalcCellValue("Sheet1", "A1")
6495+
assert.Error(t, err, "A1 calc is wrong")
6496+
}
6497+
6498+
func TestTableReferenceToNotExistingTable(t *testing.T) {
6499+
f := sheetWithTables(t)
6500+
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(NotExisting[Column1], 1)"), "cell formula for A1")
6501+
6502+
_, err := f.CalcCellValue("Sheet1", "A1")
6503+
assert.Error(t, err, "A1 calc is wrong")
6504+
}
6505+
6506+
func TestTableReferenceToNotExistingColumn(t *testing.T) {
6507+
f := sheetWithTables(t)
6508+
assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[NotExisting], 1)"), "cell formula for A1")
6509+
6510+
_, err := f.CalcCellValue("Sheet1", "A1")
6511+
assert.Error(t, err, "A1 calc is wrong")
6512+
}
6513+
6514+
func sheetWithTables(t *testing.T) *File {
6515+
f := NewFile()
6516+
6517+
// Multi column with default column names
6518+
assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A2:C5", Name: "FieryTable"}), "adding FieryTable")
6519+
assert.NoError(t, f.SetCellValue("Sheet1", "A3", "Foo"), "set A3")
6520+
assert.NoError(t, f.SetCellValue("Sheet1", "B3", "12.5"), "set A3")
6521+
6522+
// Single column with renamed column
6523+
assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A8:A9", Name: "FrostyTable"}), "adding FrostyTable")
6524+
assert.NoError(t, f.SetCellValue("Sheet1", "A9", "Hedgehog"), "set A3")
6525+
6526+
return f
6527+
}
6528+
64506529
func TestEvalInfixExp(t *testing.T) {
64516530
f := NewFile()
64526531
arg, err := f.evalInfixExp(nil, "Sheet1", "A1", []efp.Token{

excelize.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import (
1616
"archive/zip"
1717
"bytes"
1818
"encoding/xml"
19+
"fmt"
1920
"io"
2021
"os"
22+
"path"
2123
"path/filepath"
2224
"strconv"
2325
"strings"
@@ -26,6 +28,8 @@ import (
2628
"golang.org/x/net/html/charset"
2729
)
2830

31+
const targetModeExternal = "external"
32+
2933
// File define a populated spreadsheet file struct.
3034
type File struct {
3135
mu sync.Mutex
@@ -39,6 +43,7 @@ type File struct {
3943
streams map[string]*StreamWriter
4044
tempFiles sync.Map
4145
xmlAttr sync.Map
46+
tableRefs sync.Map
4247
CalcChain *xlsxCalcChain
4348
CharsetReader charsetTranscoderFn
4449
Comments map[string]*xlsxComments
@@ -59,6 +64,19 @@ type File struct {
5964
WorkBook *xlsxWorkbook
6065
}
6166

67+
type tableRef struct {
68+
ref string
69+
sheet string
70+
columns []string
71+
}
72+
73+
type relationMetadata struct {
74+
wb *xlsxWorkbook
75+
wbRels *xlsxRelationships
76+
relsPerSheet map[string]*xlsxRelationships
77+
tables map[string]*xlsxTable
78+
}
79+
6280
// charsetTranscoderFn set user-defined codepage transcoder function for open
6381
// the spreadsheet from non-UTF-8 encoding.
6482
type charsetTranscoderFn func(charset string, input io.Reader) (rdr io.Reader, err error)
@@ -140,6 +158,7 @@ func newFile() *File {
140158
checked: sync.Map{},
141159
sheetMap: make(map[string]string),
142160
tempFiles: sync.Map{},
161+
tableRefs: sync.Map{},
143162
Comments: make(map[string]*xlsxComments),
144163
Drawings: sync.Map{},
145164
sharedStringsMap: make(map[string]int),
@@ -204,6 +223,10 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) {
204223
for k, v := range file {
205224
f.Pkg.Store(k, v)
206225
}
226+
227+
if err := f.storeRelations(file); err != nil {
228+
return f, err
229+
}
207230
if f.CalcChain, err = f.calcChainReader(); err != nil {
208231
return f, err
209232
}
@@ -217,6 +240,136 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) {
217240
return f, err
218241
}
219242

243+
func (f *File) storeRelations(files map[string][]byte) error {
244+
relMetadata, err := f.parseRelationMetadata(files)
245+
if err != nil {
246+
return err
247+
}
248+
if relMetadata.wb == nil || relMetadata.wbRels == nil {
249+
return nil
250+
}
251+
252+
sheetRelIDs := make(map[string]string)
253+
for _, sheet := range relMetadata.wb.Sheets.Sheet {
254+
sheetRelIDs[sheet.ID] = sheet.Name
255+
}
256+
257+
sheetBaseToSheetNames := make(map[string]string)
258+
for _, rel := range relMetadata.wbRels.Relationships {
259+
sheetName, ok := sheetRelIDs[rel.ID]
260+
261+
if !ok || strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipWorkSheet {
262+
continue
263+
}
264+
265+
sheetBaseToSheetNames[fmt.Sprintf("%s.rels", path.Base(rel.Target))] = sheetName
266+
}
267+
268+
tableBaseToSheetNames := make(map[string]string)
269+
for key, sheetRels := range relMetadata.relsPerSheet {
270+
sheetName, ok := sheetBaseToSheetNames[key]
271+
if !ok {
272+
continue
273+
}
274+
275+
for _, rel := range sheetRels.Relationships {
276+
if strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipTable {
277+
continue
278+
}
279+
280+
tableBaseToSheetNames[path.Base(rel.Target)] = sheetName
281+
}
282+
}
283+
284+
for key, t := range relMetadata.tables {
285+
if sheetName, ok := tableBaseToSheetNames[key]; ok {
286+
f.tableRefs.Store(t.Name, tableRefFromXLSXTable(t, sheetName))
287+
}
288+
}
289+
290+
return nil
291+
}
292+
293+
func (f *File) parseRelationMetadata(files map[string][]byte) (*relationMetadata, error) {
294+
var err error
295+
relMetadata := &relationMetadata{
296+
relsPerSheet: map[string]*xlsxRelationships{},
297+
tables: map[string]*xlsxTable{},
298+
}
299+
300+
for k, v := range files {
301+
switch {
302+
case strings.Contains(k, "xl/workbook.xml") && v != nil:
303+
relMetadata.wb, err = f.parseWorkbook(v)
304+
if err != nil {
305+
return nil, err
306+
}
307+
case strings.Contains(k, "xl/_rels/workbook.xml.rels") && v != nil:
308+
relMetadata.wbRels, err = f.parseRelationships(v)
309+
if err != nil {
310+
return nil, fmt.Errorf("workbook rels: %w", err)
311+
}
312+
case strings.Contains(k, "xl/worksheets/_rels") && v != nil:
313+
sheetRels, err := f.parseRelationships(v)
314+
if err != nil {
315+
return nil, fmt.Errorf("workbook sheet rel %s: %w", k, err)
316+
}
317+
relMetadata.relsPerSheet[path.Base(k)] = sheetRels
318+
case strings.Contains(k, "xl/tables") && v != nil:
319+
table, err := f.parseTable(v)
320+
if err != nil {
321+
return nil, fmt.Errorf("table %s: %w", k, err)
322+
}
323+
relMetadata.tables[path.Base(k)] = table
324+
}
325+
}
326+
327+
return relMetadata, nil
328+
}
329+
330+
func (f *File) parseWorkbook(v []byte) (*xlsxWorkbook, error) {
331+
var wb *xlsxWorkbook
332+
333+
dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
334+
if err := dec.Decode(&wb); err != nil && err != io.EOF {
335+
return nil, fmt.Errorf("decoding workbook: %w", err)
336+
}
337+
338+
return wb, nil
339+
}
340+
341+
func (f *File) parseRelationships(v []byte) (*xlsxRelationships, error) {
342+
var rels *xlsxRelationships
343+
344+
dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
345+
if err := dec.Decode(&rels); err != nil && err != io.EOF {
346+
return nil, fmt.Errorf("decoding relationships: %w", err)
347+
}
348+
349+
return rels, nil
350+
}
351+
352+
func (f *File) parseTable(v []byte) (*xlsxTable, error) {
353+
var table *xlsxTable
354+
dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v)))
355+
if err := dec.Decode(&table); err != nil && err != io.EOF {
356+
return nil, fmt.Errorf("parsing table: %w", err)
357+
}
358+
return table, nil
359+
}
360+
361+
func tableRefFromXLSXTable(t *xlsxTable, sheet string) tableRef {
362+
tblRef := tableRef{
363+
ref: t.Ref,
364+
sheet: sheet,
365+
columns: make([]string, 0, t.TableColumns.Count),
366+
}
367+
for _, col := range t.TableColumns.TableColumn {
368+
tblRef.columns = append(tblRef.columns, col.Name)
369+
}
370+
return tblRef
371+
}
372+
220373
// getOptions provides a function to parse the optional settings for open
221374
// and reading spreadsheet.
222375
func (f *File) getOptions(opts ...Options) *Options {

excelize_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ func TestOpenReader(t *testing.T) {
288288
defaultXMLPathWorkbookRels,
289289
} {
290290
_, err = OpenReader(preset(defaultXMLPath, false))
291-
assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8")
291+
assert.ErrorContains(t, err, "XML syntax error on line 1: invalid UTF-8")
292292
}
293293
// Test open workbook without internal XML parts
294294
for _, defaultXMLPath := range []string{

0 commit comments

Comments
 (0)