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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy