From 9b99250c8d08d16faa56a944b605c828d96c4f34 Mon Sep 17 00:00:00 2001 From: Davide Cifariello Date: Thu, 24 Jul 2025 12:28:33 +0200 Subject: [PATCH] Add support for writing struct arrays to excel table Adds support for exporting slices of tagged structs to Excel in a tabular format. Fields are read via struct tags and written as column headers. Supports basic types. --- model2table.go | 209 ++++++++++++++++++++++++++++++++++++++++++++ model2table_test.go | 203 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 model2table.go create mode 100644 model2table_test.go diff --git a/model2table.go b/model2table.go new file mode 100644 index 0000000000..c24fde6246 --- /dev/null +++ b/model2table.go @@ -0,0 +1,209 @@ +// Copyright 2016 - 2025 The excelize Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in +// the LICENSE file. +// +// Package excelize providing a set of functions that allow you to write to and +// read from XLAM / XLSM / XLSX / XLTM / XLTX files. Supports reading and +// writing spreadsheet documents generated by Microsoft Excelâ„¢ 2007 and later. +// Supports complex components by high compatibility, and provided streaming +// API for generating or reading data from a worksheet with huge amounts of +// data. This library needs Go version 1.23 or later. + +package excelize + +import ( + "errors" + "fmt" + "reflect" + "strconv" +) + +// modelTableRow represents a row in the Excel table. +// It contains an index for the row and a slice of modelTableCol which represents the columns in that row. +type modelTableRow struct { + Index int + Cols []modelTableCol +} + +// modelTableCol represents a column in the Excel table. +// It contains the column name, the field name from the struct, and the value to be written. +type modelTableCol struct { + ColName string + FieldName string + Value interface{} +} + +// ModelTableOptions defines options for writing models into an Excel table +// It can include options like whether to include a header row. +type ModelTableOptions struct { + HasHeader bool +} + +// WriteStructsIntoFile +// writes a list of structs into an excel file +// the struct must have the tag "column" with the name of the column to write +// header is optional and can be specified with the tag "columnHeader" +func WriteStructsIntoFile[T any](f *File, structs []T, o *ModelTableOptions) error { + if f == nil { + return errors.New("nil file") + } + + var rows = constructRows(structs) + + index := f.GetActiveSheetIndex() + sheetName := f.GetSheetName(index) + if o != nil && o.HasHeader { + writeHeader[T](f, sheetName) + } + writeRows(f, rows, sheetName, o) + + return nil +} + +// writeHeader writes the header of the columns putting by default the name of the field or eventually the value +// specified by the columnHeader tag +func writeHeader[T any](f *File, sheetName string) error { + var t T + fieldColumnMap := getTagValues(t, "column") + columnAliasMap := getTagValues(t, "columnHeader") + for k, v := range fieldColumnMap { + cellReference := v + strconv.Itoa(1) + headerColName, found := columnAliasMap[k] + var err error + if found { + err = f.SetCellValue(sheetName, cellReference, headerColName) + } else { + err = f.SetCellValue(sheetName, cellReference, k) + } + if err != nil { + return err + } + } + return nil +} + +// writeRows writes the rows in the file, the row index changes depending on the presence of the header +func writeRows(f *File, rows []modelTableRow, sheetName string, o *ModelTableOptions) error { + for i, r := range rows { + for _, c := range r.Cols { + var cellReference string + if o != nil && o.HasHeader { + cellReference = c.ColName + strconv.Itoa(i+2) + } else { + cellReference = c.ColName + strconv.Itoa(i+1) + } + cellValue := c.Value + err := f.SetCellValue(sheetName, cellReference, cellValue) + if err != nil { + return fmt.Errorf("error setting cell value at %s: %w", cellReference, err) + } + } + } + return nil +} + +// getFieldValue +// given a struct and the name of a field returns the value of that field for that struct +// and a boolean that indicates whether the field has been found or not +func getFieldValue(i interface{}, fieldName string) (interface{}, bool) { + v := reflect.ValueOf(i) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil, false + } + f := v.FieldByName(fieldName) + if !f.IsValid() { + return nil, false + } + return f.Interface(), true +} + +// getTagValues +// returns a map of string to string with the field name as key and the value of the requested tag as value +func getTagValues(i interface{}, tagName string) map[string]string { + v := reflect.ValueOf(i) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + t := v.Type() + fieldCount := v.NumField() + tagValues := make(map[string]string) + for i := 0; i < fieldCount; i++ { + field := t.Field(i) + tag := field.Tag.Get(tagName) + if tag != "" { + tagValues[field.Name] = tag + } + } + return tagValues +} + +// constructRows constructs the "rows" of the excel by taking the column name +// and retrieving the value of the struct field from the field name +func constructRows[T any](structs []T) []modelTableRow { + var rows []modelTableRow + + for i, item := range structs { + fieldColumnMap := getTagValues(item, "column") + var cols []modelTableCol + for fieldName, columnName := range fieldColumnMap { + value, b := getFieldValue(item, fieldName) + if b { + column := constructCol(columnName, fieldName, value) + cols = append(cols, column) + } + } + toAdd := modelTableRow{ + Index: i, + Cols: cols, + } + rows = append(rows, toAdd) + } + + return rows +} + +// constructCol constructs a column of the excel by taking the column name +// and retrieving the value of the struct field from the field name +func constructCol(columnName string, fieldName string, value interface{}) modelTableCol { + var parsed interface{} + kind := reflect.ValueOf(value).Type().Kind() + + // checking if the value is a nil pointer + if kind == reflect.Pointer { + if reflect.ValueOf(value).IsNil() { + parsed = "" + } else { + parsed = reflect.ValueOf(value).Elem().Interface() + } + } else if kind == reflect.Struct { + // if the value is a nested struct, take the values to show... + structItemsMap := getTagValues(value, "columnInnerValue") + if len(structItemsMap) > 0 { + parsed = "" + for k, v := range structItemsMap { + fieldValue, found := getFieldValue(value, k) + if found { + //...and format them as a string + parsed = fmt.Sprintf("%s %s %v \n", parsed, v, fieldValue) + } + } + } else { + parsed = value + } + } else { + parsed = value + } + + column := modelTableCol{ + ColName: columnName, + FieldName: fieldName, + Value: parsed, + } + return column +} diff --git a/model2table_test.go b/model2table_test.go new file mode 100644 index 0000000000..14a6b46ef2 --- /dev/null +++ b/model2table_test.go @@ -0,0 +1,203 @@ +package excelize + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestWriteStructsIntoFile(t *testing.T) { + t.Run("Should write structs into file with header", func(t *testing.T) { + f := NewFile() + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + } + var testStructs = []testStruct{ + {Column1: "1", Column2: "2"}, + {Column1: "3", Column2: "4"}, + } + assert.NoError(t, WriteStructsIntoFile(f, testStructs, &ModelTableOptions{HasHeader: true})) + + rows, err := f.GetRows("Sheet1") + assert.NoError(t, err) + assert.Equal(t, 3, len(rows)) + }) + + t.Run("Should write structs into file without header", func(t *testing.T) { + f := NewFile() + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B"` + } + var testStructs = []testStruct{ + {Column1: "1", Column2: "2"}, + {Column1: "3", Column2: "4"}, + } + assert.NoError(t, WriteStructsIntoFile(f, testStructs, &ModelTableOptions{HasHeader: true})) + + rows, err := f.GetRows("Sheet1") + assert.NoError(t, err) + assert.Equal(t, 3, len(rows)) + }) + + t.Run("Should write structs into file with header and nested values", func(t *testing.T) { + f := NewFile() + amount := 5 + testString := "test string" + // fields with no tag should be ignored + type address struct { + City string `columnInnerValue:"City:"` + Country string `columnInnerValue:"Country:"` + Number int + } + type user struct { + Name string `column:"A"` + Age int `column:"B"` + AddressStr string `column:"C"` + Birthdate time.Time `column:"D" columnHeader:"Birthday"` + Pointer *string `column:"E"` + Address address `column:"F" columnHeader:"Indirizzo"` + Tst string + } + testUser := user{ + Name: "John Doe", + Age: 30, + AddressStr: "123 Main St", + Birthdate: time.Date(1993, 1, 1, 0, 0, 0, 0, time.UTC), + Pointer: &testString, + Address: address{ + City: "New York", + Country: "USA", + Number: 123, + }, + } + var users []user + for i := 0; i < amount; i++ { + users = append(users, testUser) + } + + assert.NoError(t, WriteStructsIntoFile[user](f, users, &ModelTableOptions{HasHeader: true})) + + rows, err := f.GetRows("Sheet1") + assert.NoError(t, err) + // adding 1 for header + assert.Equal(t, amount+1, len(rows)) + + }) + + t.Run("Should write structs into file with no options", func(t *testing.T) { + f := NewFile() + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + } + var testStructs = []testStruct{ + {Column1: "1", Column2: "2"}, + {Column1: "3", Column2: "4"}, + } + assert.NoError(t, WriteStructsIntoFile(f, testStructs, nil)) + + rows, err := f.GetRows("Sheet1") + assert.NoError(t, err) + assert.Equal(t, len(testStructs), len(rows)) + }) + + t.Run("Should return error if the file is nil", func(t *testing.T) { + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + } + var testStructs = []testStruct{ + {Column1: "1", Column2: "2"}, + {Column1: "3", Column2: "4"}, + } + assert.Error(t, WriteStructsIntoFile(nil, testStructs, &ModelTableOptions{HasHeader: true})) + }) + +} + +func TestGetTagValues(t *testing.T) { + t.Run("Should get tag values", func(t *testing.T) { + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + Column3 *string `column:"C" columnHeader:"Column 3"` + } + var t1 testStruct + fieldColumnMap := getTagValues(t1, "column") + columnAliasMap := getTagValues(t1, "columnHeader") + + assert.Equal(t, 3, len(fieldColumnMap)) + assert.Equal(t, 3, len(columnAliasMap)) + }) + t.Run("Should get tag values using pointers", func(t *testing.T) { + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + Column3 *string `column:"C" columnHeader:"Column 3"` + } + var t1 testStruct + fieldColumnMap := getTagValues(&t1, "column") + columnAliasMap := getTagValues(&t1, "columnHeader") + + assert.Equal(t, 3, len(fieldColumnMap)) + assert.Equal(t, 3, len(columnAliasMap)) + }) +} + +func TestConstructRows(t *testing.T) { + type testStruct struct { + Column1 string `column:"A" columnHeader:"Column 1"` + Column2 string `column:"B" columnHeader:"Column 2"` + } + var testStructs = []testStruct{ + {Column1: "1", Column2: "2"}, + {Column1: "3", Column2: "4"}, + } + rows := constructRows(testStructs) + assert.Equal(t, 2, len(rows)) +} + +func TestGetFieldValue(t *testing.T) { + type sample struct { + A string + B int + } + + t.Run("should return true with a string", func(t *testing.T) { + s := sample{A: "test", B: 42} + val, ok := getFieldValue(s, "A") + assert.True(t, ok) + assert.Equal(t, "test", val) + }) + + t.Run("should return true with an int", func(t *testing.T) { + s := sample{A: "test", B: 42} + val, ok := getFieldValue(s, "B") + assert.True(t, ok) + assert.Equal(t, 42, val) + }) + + t.Run("should return false with no field", func(t *testing.T) { + s := sample{A: "test", B: 42} + val, ok := getFieldValue(s, "C") + assert.False(t, ok) + assert.Nil(t, val) + }) + + t.Run("should return true with a pointer to a struct", func(t *testing.T) { + s := sample{A: "test", B: 42} + ps := &s + val, ok := getFieldValue(ps, "A") + assert.True(t, ok) + assert.Equal(t, "test", val) + }) + + t.Run("should return false", func(t *testing.T) { + val, ok := getFieldValue(123, "A") + assert.False(t, ok) + assert.Nil(t, val) + }) +}