mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00: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
|
||||
}
|
||||
|
Reference in New Issue
Block a user