mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add generic table formatter (#3415)
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/structtag"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Table creates a new table with standardized styles.
|
||||
@@ -41,3 +46,258 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
||||
}
|
||||
return columnConfigs
|
||||
}
|
||||
|
||||
// DisplayTable renders a table as a string. The input argument must be a slice
|
||||
// of structs. At least one field in the struct must have a `table:""` tag
|
||||
// containing the name of the column in the outputted table.
|
||||
//
|
||||
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
|
||||
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
|
||||
// malformed or a field is marked as recursive but does not contain a struct or
|
||||
// a pointer to a struct, this function will return an error (even with an empty
|
||||
// input slice).
|
||||
//
|
||||
// If sort is empty, the input order will be used. If filterColumns is empty or
|
||||
// nil, all available columns are included.
|
||||
func DisplayTable(out any, sort string, filterColumns []string) (string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
if v.Kind() != reflect.Slice {
|
||||
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headersRaw, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||
}
|
||||
if len(headersRaw) == 0 {
|
||||
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
|
||||
}
|
||||
headers := make(table.Row, len(headersRaw))
|
||||
for i, header := range headersRaw {
|
||||
headers[i] = header
|
||||
}
|
||||
|
||||
// Verify that the given sort column and filter columns are valid.
|
||||
if sort != "" || len(filterColumns) != 0 {
|
||||
headersMap := make(map[string]string, len(headersRaw))
|
||||
for _, header := range headersRaw {
|
||||
headersMap[strings.ToLower(header)] = header
|
||||
}
|
||||
|
||||
if sort != "" {
|
||||
sort = strings.ToLower(strings.ReplaceAll(sort, "_", " "))
|
||||
h, ok := headersMap[sort]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
|
||||
// Autocorrect
|
||||
sort = h
|
||||
}
|
||||
|
||||
for i, column := range filterColumns {
|
||||
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
||||
h, ok := headersMap[column]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("specified filter column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
|
||||
// Autocorrect
|
||||
filterColumns[i] = h
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the given sort column is valid.
|
||||
if sort != "" {
|
||||
sort = strings.ReplaceAll(sort, "_", " ")
|
||||
found := false
|
||||
for _, header := range headersRaw {
|
||||
if strings.EqualFold(sort, header) {
|
||||
found = true
|
||||
sort = header
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the table formatter.
|
||||
tw := Table()
|
||||
tw.AppendHeader(headers)
|
||||
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
|
||||
if sort != "" {
|
||||
tw.SortBy([]table.SortBy{{
|
||||
Name: sort,
|
||||
}})
|
||||
}
|
||||
|
||||
// Write each struct to the table.
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
// Format the row as a slice.
|
||||
rowMap, err := valueToTableMap(v.Index(i))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table row map %v: %w", i, err)
|
||||
}
|
||||
|
||||
rowSlice := make([]any, len(headers))
|
||||
for i, h := range headersRaw {
|
||||
v, ok := rowMap[h]
|
||||
if !ok {
|
||||
v = nil
|
||||
}
|
||||
|
||||
// Special type formatting.
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
v = val.Format(time.Stamp)
|
||||
case *time.Time:
|
||||
if val != nil {
|
||||
v = val.Format(time.Stamp)
|
||||
}
|
||||
}
|
||||
|
||||
rowSlice[i] = v
|
||||
}
|
||||
|
||||
tw.AppendRow(table.Row(rowSlice))
|
||||
}
|
||||
|
||||
return tw.Render(), nil
|
||||
}
|
||||
|
||||
// parseTableStructTag returns the name of the field according to the `table`
|
||||
// struct tag. If the table tag does not exist or is "-", an empty string is
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
recursive := false
|
||||
for _, opt := range tag.Options {
|
||||
if opt == "recursive" {
|
||||
recursive = true
|
||||
continue
|
||||
}
|
||||
|
||||
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)
|
||||
}
|
||||
|
||||
// typeToTableHeaders converts a type to a slice of column names. If the given
|
||||
// type is invalid (not a struct or a pointer to a struct, has invalid table
|
||||
// tags, etc.), an error is returned.
|
||||
func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
||||
if !isStructOrStructPointer(t) {
|
||||
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
|
||||
}
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
headers := []string{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
|
||||
}
|
||||
|
||||
childNames, err := typeToTableHeaders(fieldType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
}
|
||||
for _, childName := range childNames {
|
||||
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
headers = append(headers, name)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// valueToTableMap converts a struct to a map of column name to value. If the
|
||||
// given type is invalid (not a struct or a pointer to a struct, has invalid
|
||||
// table tags, etc.), an error is returned.
|
||||
func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
if !isStructOrStructPointer(val.Type()) {
|
||||
return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type")
|
||||
}
|
||||
if val.Kind() == reflect.Pointer {
|
||||
if val.IsNil() {
|
||||
// No data for this struct, so return an empty map. All values will
|
||||
// be rendered as nil in the resulting table.
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
row := map[string]any{}
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||
}
|
||||
|
||||
// valueToTableMap does nothing on pointers so we don't need to
|
||||
// filter here.
|
||||
childMap, err := valueToTableMap(fieldVal)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
}
|
||||
for childName, childValue := range childMap {
|
||||
row[fmt.Sprintf("%s %s", name, childName)] = childValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, we just use the field value.
|
||||
row[name] = val.Field(i).Interface()
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
type tableTest1 struct {
|
||||
Name string `table:"name"`
|
||||
NotIncluded string // no table tag
|
||||
Age int `table:"age"`
|
||||
Roles []string `table:"roles"`
|
||||
Sub1 tableTest2 `table:"sub_1,recursive"`
|
||||
Sub2 *tableTest2 `table:"sub_2,recursive"`
|
||||
Sub3 tableTest3 `table:"sub 3,recursive"`
|
||||
Sub4 tableTest2 `table:"sub 4"` // not recursive
|
||||
|
||||
// Types with special formatting.
|
||||
Time time.Time `table:"time"`
|
||||
TimePtr *time.Time `table:"time_ptr"`
|
||||
}
|
||||
|
||||
type tableTest2 struct {
|
||||
Name string `table:"name"`
|
||||
Age int `table:"age"`
|
||||
NotIncluded string `table:"-"`
|
||||
}
|
||||
|
||||
type tableTest3 struct {
|
||||
NotIncluded string // no table tag
|
||||
Sub tableTest2 `table:"inner,recursive"`
|
||||
}
|
||||
|
||||
func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local)
|
||||
in := []tableTest1{
|
||||
{
|
||||
Name: "foo",
|
||||
Age: 10,
|
||||
Roles: []string{"a", "b", "c"},
|
||||
Sub1: tableTest2{
|
||||
Name: "foo1",
|
||||
Age: 11,
|
||||
},
|
||||
Sub2: &tableTest2{
|
||||
Name: "foo2",
|
||||
Age: 12,
|
||||
},
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: "foo3",
|
||||
Age: 13,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: "foo4",
|
||||
Age: 14,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: &someTime,
|
||||
},
|
||||
{
|
||||
Name: "bar",
|
||||
Age: 20,
|
||||
Roles: []string{"a"},
|
||||
Sub1: tableTest2{
|
||||
Name: "bar1",
|
||||
Age: 21,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: "bar3",
|
||||
Age: 23,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: "bar4",
|
||||
Age: 24,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
{
|
||||
Name: "baz",
|
||||
Age: 30,
|
||||
Roles: nil,
|
||||
Sub1: tableTest2{
|
||||
Name: "baz1",
|
||||
Age: 31,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: "baz3",
|
||||
Age: 33,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: "baz4",
|
||||
Age: 34,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// This test tests skipping fields without table tags, recursion, pointer
|
||||
// dereferencing, and nil pointer skipping.
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
`
|
||||
|
||||
// Test with non-pointer values.
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
|
||||
// Test with pointer values.
|
||||
inPtr := make([]*tableTest1, len(in))
|
||||
for i, v := range in {
|
||||
v := v
|
||||
inPtr[i] = &v
|
||||
}
|
||||
out, err = cliui.DisplayTable(inPtr, "", nil)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Sort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "name", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||
foo foo1 foo3 Aug 2 15:49:10
|
||||
bar bar1 bar3 Aug 2 15:49:10
|
||||
baz baz1 baz3 Aug 2 15:49:10
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures that safeties against invalid use of `table` tags
|
||||
// causes errors (even without data).
|
||||
t.Run("Errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NotSlice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in string
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BadSortColumn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cliui.DisplayTable(in, "bad_column_does_not_exist", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BadFilterColumns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cliui.DisplayTable(in, "", []string{"name", "bad_column_does_not_exist"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Interfaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []any
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotStruct", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []string
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []string{"foo", "bar", "baz"}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoTableTags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type noTableTagsTest struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []noTableTagsTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []noTableTagsTest{{Field: "hi"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("InvalidTag/NoName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type noNameTest struct {
|
||||
Field string `table:""`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []noNameTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []noNameTest{{Field: "test"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("InvalidTag/BadSyntax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type invalidSyntaxTest struct {
|
||||
Field string `table:"asda,asdjada"`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []invalidSyntaxTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []invalidSyntaxTest{{Field: "test"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
func compareTables(t *testing.T, expected, out string) {
|
||||
t.Helper()
|
||||
|
||||
expectedLines := strings.Split(strings.TrimSpace(expected), "\n")
|
||||
gotLines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
assert.Equal(t, len(expectedLines), len(gotLines), "expected line count does not match generated line count")
|
||||
|
||||
// Map the expected and got lines to normalize them.
|
||||
expectedNormalized := make([]string, len(expectedLines))
|
||||
gotNormalized := make([]string, len(gotLines))
|
||||
normalizeLine := func(s string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||
}
|
||||
for i, s := range expectedLines {
|
||||
expectedNormalized[i] = normalizeLine(s)
|
||||
}
|
||||
for i, s := range gotLines {
|
||||
gotNormalized[i] = normalizeLine(s)
|
||||
}
|
||||
|
||||
require.Equal(t, expectedNormalized, gotNormalized, "expected lines to match generated lines")
|
||||
}
|
||||
Reference in New Issue
Block a user