From ae1959f6d06c1a6faedb76969830b51d05fb6afd Mon Sep 17 00:00:00 2001 From: vrancurel Date: Thu, 24 Jul 2025 11:21:31 -0700 Subject: [PATCH] This commit adds several new features to the table library while maintaining full backward compatibility with the existing Table interface: - CSV/JSON Export: ExportCSV() and ExportJSON() methods for data export - Table Sorting: SortBy() and SortByMultiple() with comparison functions (StringComparison, NumericalComparison, CurrencyComparison, etc.) - Table Transpose: Transpose() method to swap rows and columns - Alternating Row Formatting: WithStandoutFormatter() for zebra-striped tables --- go.mod | 11 +- go.sum | 9 -- table.go | 386 +++++++++++++++++++++++++++++++++++++++++++++++--- table_test.go | 2 +- 4 files changed, 377 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index 8087c65..12ba190 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,18 @@ module github.com/rodaine/table -go 1.14 +go 1.21 + +toolchain go1.22.8 require ( github.com/google/go-cmp v0.7.0 github.com/mattn/go-runewidth v0.0.16 github.com/stretchr/testify v1.10.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 45a0147..9f82f4f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -9,17 +8,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/table.go b/table.go index ccd5124..ea0480e 100644 --- a/table.go +++ b/table.go @@ -26,9 +26,13 @@ package table import ( + "encoding/csv" + "encoding/json" "fmt" "io" "os" + "sort" + "strconv" "strings" "unicode/utf8" ) @@ -48,6 +52,9 @@ var ( // DefaultFirstColumnFormatter specifies the default Formatter for the first column cells. DefaultFirstColumnFormatter Formatter + // DefaultStandoutFormatter specifies the default Formatter for alternating rows. + DefaultStandoutFormatter Formatter + // DefaultWidthFunc specifies the default WidthFunc for calculating column widths DefaultWidthFunc WidthFunc = utf8.RuneCountInString @@ -76,6 +83,15 @@ type Formatter func(string, ...interface{}) string // accomodate multi-cell characters (such as emoji or CJK characters). type WidthFunc func(string) int +// ComparisonFunc is a function type for comparing two strings. +type ComparisonFunc func(string, string) int + +// SortCriterion represents a single column index and its comparison function. +type SortCriterion struct { + ColumnIndex int + Compare ComparisonFunc +} + // Table describes the interface for building up a tabular representation of data. // It exposes fluent/chainable methods for convenient table building. // @@ -125,6 +141,7 @@ type WidthFunc func(string) int type Table interface { WithHeaderFormatter(f Formatter) Table WithFirstColumnFormatter(f Formatter) Table + WithStandoutFormatter(f Formatter) Table WithPadding(p int) Table WithWriter(w io.Writer) Table WithWidthFunc(f WidthFunc) Table @@ -132,8 +149,23 @@ type Table interface { WithPrintHeaders(b bool) Table AddRow(vals ...interface{}) Table + AddRowAsArray(vals []interface{}) Table SetRows(rows [][]string) Table + AddHeader(col string) + AddHeaders(cols ...string) + AddHeadersAsArray(cols []string) Print() + + // New export methods + ExportCSV() error + ExportJSON(keyColumn int) error + + // New sorting methods + SortBy(columnIndex int, cmpFn ComparisonFunc) error + SortByMultiple(criteria []*SortCriterion) error + + // New transpose method + Transpose() Table } // New creates a Table instance with the specified header(s) provided. The number @@ -146,6 +178,7 @@ func New(columnHeaders ...interface{}) Table { t.WithWriter(DefaultWriter) t.WithHeaderFormatter(DefaultHeaderFormatter) t.WithFirstColumnFormatter(DefaultFirstColumnFormatter) + t.WithStandoutFormatter(DefaultStandoutFormatter) t.WithWidthFunc(DefaultWidthFunc) t.WithPrintHeaders(DefaultPrintHeaders) @@ -156,9 +189,52 @@ func New(columnHeaders ...interface{}) Table { return &t } +// AddHeader adds a single column header to the table. +func (t *table) AddHeader(col string) { + t.header = append(t.header, fmt.Sprint(col)) +} + +// AddHeaders adds multiple column headers to the table. +func (t *table) AddHeaders(cols ...string) { + t.header = append(t.header, cols...) +} + +// AddHeadersAsArray adds an array of column headers to the table. +func (t *table) AddHeadersAsArray(cols []string) { + for _, col := range cols { + t.header = append(t.header, col) + } +} + +// AddRowAsArray adds a row to the table using the provided values in +// an array and returns the modified table. Each element in the +// 'vals' slice represents a column in the row. If the value in a +// column contains multiple lines separated by newline characters, +// each line will be placed in a separate row in the table. +func (t *table) AddRowAsArray(vals []interface{}) Table { + maxNumNewlines := 0 + for _, val := range vals { + maxNumNewlines = max(strings.Count(fmt.Sprint(val), "\n"), maxNumNewlines) + } + for i := 0; i <= maxNumNewlines; i++ { + row := make([]string, len(t.header)) + for j, val := range vals { + if j >= len(t.header) { + break + } + v := strings.Split(fmt.Sprint(val), "\n") + row[j] = safeOffset(v, i) + } + t.rows = append(t.rows, row) + } + + return t +} + type table struct { FirstColumnFormatter Formatter HeaderFormatter Formatter + StandoutFormatter Formatter Padding int Writer io.Writer Width WidthFunc @@ -185,6 +261,11 @@ func (t *table) WithFirstColumnFormatter(f Formatter) Table { return t } +func (t *table) WithStandoutFormatter(f Formatter) Table { + t.StandoutFormatter = f + return t +} + func (t *table) WithPadding(p int) Table { if p < 0 { p = 0 @@ -214,23 +295,7 @@ func (t *table) WithPrintHeaders(b bool) Table { } func (t *table) AddRow(vals ...interface{}) Table { - maxNumNewlines := 0 - for _, val := range vals { - maxNumNewlines = max(strings.Count(fmt.Sprint(val), "\n"), maxNumNewlines) - } - for i := 0; i <= maxNumNewlines; i++ { - row := make([]string, len(t.header)) - for j, val := range vals { - if j >= len(t.header) { - break - } - v := strings.Split(fmt.Sprint(val), "\n") - row[j] = safeOffset(v, i) - } - t.rows = append(t.rows, row) - } - - return t + return t.AddRowAsArray(vals) } func (t *table) SetRows(rows [][]string) Table { @@ -260,8 +325,8 @@ func (t *table) Print() { } } - for _, row := range t.rows { - t.printRow(format, row) + for i, row := range t.rows { + t.printRow(format, row, i%2 == 1) } } @@ -305,12 +370,18 @@ func (t *table) printHeader(format string) { } } -func (t *table) printRow(format string, row []string) { +func (t *table) printRow(format string, row []string, alternate bool) { vals := t.applyWidths(row, t.widths) if t.FirstColumnFormatter != nil { vals[0] = t.FirstColumnFormatter("%s", vals[0]) } + + if alternate && t.StandoutFormatter != nil { + for i := range vals { + vals[i] = t.StandoutFormatter("%s", vals[i]) + } + } fmt.Fprintf(t.Writer, format, vals...) } @@ -361,3 +432,278 @@ func safeOffset(sarr []string, idx int) string { } return sarr[idx] } + +// ExportCSV exports the table data in CSV format. +func (t *table) ExportCSV() error { + // Create a CSV writer + writer := csv.NewWriter(t.Writer) + defer writer.Flush() + + // Write the header row + if err := writer.Write(t.header); err != nil { + return fmt.Errorf("failed to write header: %v", err) + } + + // Write each row + for _, row := range t.rows { + if len(row) < len(t.header) { + // Ensure the row has the same number of columns as the header + paddedRow := make([]string, len(t.header)) + copy(paddedRow, row) + row = paddedRow + } + if err := writer.Write(row); err != nil { + return fmt.Errorf("failed to write row: %v", err) + } + } + + // Check for any errors that occurred during writing + if err := writer.Error(); err != nil { + return fmt.Errorf("error occurred while writing CSV: %v", err) + } + + return nil +} + +// ExportJSON exports the table data in JSON format using a specified column as the key. +func (t *table) ExportJSON(keyColumn int) error { + if keyColumn < 0 || keyColumn >= len(t.header) { + return fmt.Errorf("invalid keyColumn index") + } + + // Create a map to hold the exportable data + data := make(map[string]map[string]string) + + // Populate the map with table's data + for _, row := range t.rows { + if keyColumn >= len(row) { + return fmt.Errorf("keyColumn index out of range in row") + } + + key := row[keyColumn] + rowMap := make(map[string]string) + for i, col := range t.header { + if i < len(row) { + rowMap[col] = row[i] + } else { + rowMap[col] = "" + } + } + data[key] = rowMap + } + + // Marshal the data into JSON + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + // Write the JSON data to the Writer + _, err = t.Writer.Write(jsonData) + return err +} + +// SortBy sorts the table rows based on the values in the specified +// column (indexed from 0) using the provided comparison function. It +// returns an error if the specified column index is out of bounds. +func (t *table) SortBy(columnIndex int, cmpFn ComparisonFunc) error { + // Check if columnIndex is a valid column index + if columnIndex < 0 || columnIndex >= len(t.header) { + return fmt.Errorf("invalid column index %d (len %d)", columnIndex, len(t.header)) + } + + // Sort rows based on the values in the specified column using the provided comparison function + sort.SliceStable(t.rows, func(i, j int) bool { + return cmpFn(t.rows[i][columnIndex], t.rows[j][columnIndex]) < 0 + }) + + return nil +} + +// SortByMultiple sorts the table rows based on multiple sorting criteria. +// Each criterion specifies a column index and its comparison function. +// It returns an error if any specified column index is out of bounds. +func (t *table) SortByMultiple(criteria []*SortCriterion) error { + for _, criterion := range criteria { + // Check if each column index is valid + if criterion.ColumnIndex < 0 || criterion.ColumnIndex >= len(t.header) { + return fmt.Errorf("invalid column index %d (len %d)", criterion.ColumnIndex, len(t.header)) + } + } + + // Sort rows based on the values in the specified columns using the provided comparison functions + sort.SliceStable(t.rows, func(i, j int) bool { + for _, criterion := range criteria { + colIdx := criterion.ColumnIndex + cmpFn := criterion.Compare + + // Compare rows based on the current criterion + result := cmpFn(t.rows[i][colIdx], t.rows[j][colIdx]) + if result < 0 { + return true + } else if result > 0 { + return false + } + // If the values are equal, continue to the next criterion + } + return false + }) + + return nil +} + +// Transpose the table such that the first column becomes the header and the rest of the data is transposed accordingly. +func (t *table) Transpose() Table { + // Create new header from the first column of each row, plus the first header element + newHeader := make([]string, 0) + newHeader = append(newHeader, t.header[0]) + for i := 0; i < len(t.rows); i++ { + newHeader = append(newHeader, t.rows[i][0]) + } + + // Initialize new rows + newRows := make([][]string, len(t.header)-1) + for i := range newRows { + newRows[i] = make([]string, len(t.rows)+1) + } + + // Fill in the new rows with transposed data + for i, header := range t.header[1:] { + newRows[i][0] = header + for j, row := range t.rows { + if i+1 < len(row) { + newRows[i][j+1] = row[i+1] + } else { + newRows[i][j+1] = "" + } + } + } + + // Create new table with transposed data + newTable := &table{ + FirstColumnFormatter: t.FirstColumnFormatter, + HeaderFormatter: t.HeaderFormatter, + StandoutFormatter: t.StandoutFormatter, + Padding: t.Padding, + Writer: t.Writer, + Width: t.Width, + HeaderSeparatorRune: t.HeaderSeparatorRune, + PrintHeaders: t.PrintHeaders, + header: newHeader, + rows: newRows, + } + + return newTable +} + +// Comparison functions + +// InverseComparison takes a ComparisonFunc and returns a new ComparisonFunc that inverts the result. +func InverseComparison(cmp ComparisonFunc) ComparisonFunc { + return func(a, b string) int { + return -cmp(a, b) + } +} + +// StringComparison compares two strings +func StringComparison(a, b string) int { + if a < b { + return -1 + } else if a > b { + return 1 + } + return 0 +} + +// BoolComparison compares two bools and returns -1, 0, or 1 +func BoolComparison(a, b string) int { + _a, errA := strconv.ParseBool(a) + _b, errB := strconv.ParseBool(b) + if errA != nil || errB != nil { + if errA != nil && errB != nil { + return 0 + } else if errA != nil { + return -1 + } + return 1 + } + if _a == _b { + return 0 + } else if !_a && _b { + return -1 + } + return 1 +} + +// NumericalComparison compares two numbers (parsed from strings) and returns -1, 0, or 1 +func NumericalComparison(a, b string) int { + numA, errA := strconv.ParseFloat(a, 64) + numB, errB := strconv.ParseFloat(b, 64) + if errA != nil || errB != nil { + if errA != nil && errB != nil { + return 0 + } else if errA != nil { + return 1 + } + return -1 + } + if numA < numB { + return -1 + } else if numA > numB { + return 1 + } + return 0 +} + +// parseCurrencyString parses a string representing a currency value like "$511,753,000.00" into a float64 +func parseCurrencyString(s string) (float64, error) { + cleanStr := strings.ReplaceAll(s, "$", "") + cleanStr = strings.ReplaceAll(cleanStr, ",", "") + return strconv.ParseFloat(cleanStr, 64) +} + +// CurrencyComparison compares two currency values (parsed from strings) and returns -1, 0, or 1 +func CurrencyComparison(a, b string) int { + numA, errA := parseCurrencyString(a) + numB, errB := parseCurrencyString(b) + if errA != nil || errB != nil { + if errA != nil && errB != nil { + return 0 + } else if errA != nil { + return 1 + } + return -1 + } + if numA < numB { + return -1 + } else if numA > numB { + return 1 + } + return 0 +} + +// parsePercentString parses a string representing a percent value like "10%" into a float64 +func parsePercentString(s string) (float64, error) { + cleanStr := strings.ReplaceAll(s, "%", "") + return strconv.ParseFloat(cleanStr, 64) +} + +// PercentComparison compares two percent values (parsed from strings) and returns -1, 0, or 1 +func PercentComparison(a, b string) int { + numA, errA := parsePercentString(a) + numB, errB := parsePercentString(b) + if errA != nil || errB != nil { + if errA != nil && errB != nil { + return 0 + } else if errA != nil { + return 1 + } + return -1 + } + if numA < numB { + return -1 + } else if numA > numB { + return 1 + } + return 0 +} diff --git a/table_test.go b/table_test.go index 992c9f6..51ef367 100644 --- a/table_test.go +++ b/table_test.go @@ -18,7 +18,7 @@ func TestFormatter(t *testing.T) { var formatter Formatter - fn := func(a string, b ...interface{}) string { return "" } + fn := func(_ string, _ ...interface{}) string { return "" } f := Formatter(fn) assert.IsType(t, formatter, f)