Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a872330

Browse files
authored
feat: add generic table formatter (#3415)
1 parent b1b2d1b commit a872330

File tree

9 files changed

+619
-36
lines changed

9 files changed

+619
-36
lines changed

cli/cliui/table.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package cliui
22

33
import (
4+
"fmt"
5+
"reflect"
46
"strings"
7+
"time"
58

9+
"github.com/fatih/structtag"
610
"github.com/jedib0t/go-pretty/v6/table"
11+
"golang.org/x/xerrors"
712
)
813

914
// Table creates a new table with standardized styles.
@@ -41,3 +46,258 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
4146
}
4247
return columnConfigs
4348
}
49+
50+
// DisplayTable renders a table as a string. The input argument must be a slice
51+
// of structs. At least one field in the struct must have a `table:""` tag
52+
// containing the name of the column in the outputted table.
53+
//
54+
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
55+
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
56+
// malformed or a field is marked as recursive but does not contain a struct or
57+
// a pointer to a struct, this function will return an error (even with an empty
58+
// input slice).
59+
//
60+
// If sort is empty, the input order will be used. If filterColumns is empty or
61+
// nil, all available columns are included.
62+
func DisplayTable(out any, sort string, filterColumns []string) (string, error) {
63+
v := reflect.Indirect(reflect.ValueOf(out))
64+
65+
if v.Kind() != reflect.Slice {
66+
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
67+
}
68+
69+
// Get the list of table column headers.
70+
headersRaw, err := typeToTableHeaders(v.Type().Elem())
71+
if err != nil {
72+
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
73+
}
74+
if len(headersRaw) == 0 {
75+
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
76+
}
77+
headers := make(table.Row, len(headersRaw))
78+
for i, header := range headersRaw {
79+
headers[i] = header
80+
}
81+
82+
// Verify that the given sort column and filter columns are valid.
83+
if sort != "" || len(filterColumns) != 0 {
84+
headersMap := make(map[string]string, len(headersRaw))
85+
for _, header := range headersRaw {
86+
headersMap[strings.ToLower(header)] = header
87+
}
88+
89+
if sort != "" {
90+
sort = strings.ToLower(strings.ReplaceAll(sort, "_", " "))
91+
h, ok := headersMap[sort]
92+
if !ok {
93+
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
94+
}
95+
96+
// Autocorrect
97+
sort = h
98+
}
99+
100+
for i, column := range filterColumns {
101+
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
102+
h, ok := headersMap[column]
103+
if !ok {
104+
return "", xerrors.Errorf("specified filter column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
105+
}
106+
107+
// Autocorrect
108+
filterColumns[i] = h
109+
}
110+
}
111+
112+
// Verify that the given sort column is valid.
113+
if sort != "" {
114+
sort = strings.ReplaceAll(sort, "_", " ")
115+
found := false
116+
for _, header := range headersRaw {
117+
if strings.EqualFold(sort, header) {
118+
found = true
119+
sort = header
120+
break
121+
}
122+
}
123+
if !found {
124+
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
125+
}
126+
}
127+
128+
// Setup the table formatter.
129+
tw := Table()
130+
tw.AppendHeader(headers)
131+
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
132+
if sort != "" {
133+
tw.SortBy([]table.SortBy{{
134+
Name: sort,
135+
}})
136+
}
137+
138+
// Write each struct to the table.
139+
for i := 0; i < v.Len(); i++ {
140+
// Format the row as a slice.
141+
rowMap, err := valueToTableMap(v.Index(i))
142+
if err != nil {
143+
return "", xerrors.Errorf("get table row map %v: %w", i, err)
144+
}
145+
146+
rowSlice := make([]any, len(headers))
147+
for i, h := range headersRaw {
148+
v, ok := rowMap[h]
149+
if !ok {
150+
v = nil
151+
}
152+
153+
// Special type formatting.
154+
switch val := v.(type) {
155+
case time.Time:
156+
v = val.Format(time.Stamp)
157+
case *time.Time:
158+
if val != nil {
159+
v = val.Format(time.Stamp)
160+
}
161+
}
162+
163+
rowSlice[i] = v
164+
}
165+
166+
tw.AppendRow(table.Row(rowSlice))
167+
}
168+
169+
return tw.Render(), nil
170+
}
171+
172+
// parseTableStructTag returns the name of the field according to the `table`
173+
// struct tag. If the table tag does not exist or is "-", an empty string is
174+
// returned. If the table tag is malformed, an error is returned.
175+
//
176+
// The returned name is transformed from "snake_case" to "normal text".
177+
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
178+
tags, err := structtag.Parse(string(field.Tag))
179+
if err != nil {
180+
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
181+
}
182+
183+
tag, err := tags.Get("table")
184+
if err != nil || tag.Name == "-" {
185+
// tags.Get only returns an error if the tag is not found.
186+
return "", false, nil
187+
}
188+
189+
recursive := false
190+
for _, opt := range tag.Options {
191+
if opt == "recursive" {
192+
recursive = true
193+
continue
194+
}
195+
196+
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
197+
}
198+
199+
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
200+
}
201+
202+
func isStructOrStructPointer(t reflect.Type) bool {
203+
return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)
204+
}
205+
206+
// typeToTableHeaders converts a type to a slice of column names. If the given
207+
// type is invalid (not a struct or a pointer to a struct, has invalid table
208+
// tags, etc.), an error is returned.
209+
func typeToTableHeaders(t reflect.Type) ([]string, error) {
210+
if !isStructOrStructPointer(t) {
211+
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
212+
}
213+
if t.Kind() == reflect.Pointer {
214+
t = t.Elem()
215+
}
216+
217+
headers := []string{}
218+
for i := 0; i < t.NumField(); i++ {
219+
field := t.Field(i)
220+
name, recursive, err := parseTableStructTag(field)
221+
if err != nil {
222+
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
223+
}
224+
if name == "" {
225+
continue
226+
}
227+
228+
fieldType := field.Type
229+
if recursive {
230+
if !isStructOrStructPointer(fieldType) {
231+
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())
232+
}
233+
234+
childNames, err := typeToTableHeaders(fieldType)
235+
if err != nil {
236+
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
237+
}
238+
for _, childName := range childNames {
239+
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
240+
}
241+
continue
242+
}
243+
244+
headers = append(headers, name)
245+
}
246+
247+
return headers, nil
248+
}
249+
250+
// valueToTableMap converts a struct to a map of column name to value. If the
251+
// given type is invalid (not a struct or a pointer to a struct, has invalid
252+
// table tags, etc.), an error is returned.
253+
func valueToTableMap(val reflect.Value) (map[string]any, error) {
254+
if !isStructOrStructPointer(val.Type()) {
255+
return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type")
256+
}
257+
if val.Kind() == reflect.Pointer {
258+
if val.IsNil() {
259+
// No data for this struct, so return an empty map. All values will
260+
// be rendered as nil in the resulting table.
261+
return map[string]any{}, nil
262+
}
263+
264+
val = val.Elem()
265+
}
266+
267+
row := map[string]any{}
268+
for i := 0; i < val.NumField(); i++ {
269+
field := val.Type().Field(i)
270+
fieldVal := val.Field(i)
271+
name, recursive, err := parseTableStructTag(field)
272+
if err != nil {
273+
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
274+
}
275+
if name == "" {
276+
continue
277+
}
278+
279+
// Recurse if it's a struct.
280+
fieldType := field.Type
281+
if recursive {
282+
if !isStructOrStructPointer(fieldType) {
283+
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())
284+
}
285+
286+
// valueToTableMap does nothing on pointers so we don't need to
287+
// filter here.
288+
childMap, err := valueToTableMap(fieldVal)
289+
if err != nil {
290+
return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err)
291+
}
292+
for childName, childValue := range childMap {
293+
row[fmt.Sprintf("%s %s", name, childName)] = childValue
294+
}
295+
continue
296+
}
297+
298+
// Otherwise, we just use the field value.
299+
row[name] = val.Field(i).Interface()
300+
}
301+
302+
return row, nil
303+
}

0 commit comments

Comments
 (0)