-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathtable.go
More file actions
346 lines (307 loc) · 10.4 KB
/
table.go
File metadata and controls
346 lines (307 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
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.
func Table() table.Writer {
tableWriter := table.NewWriter()
tableWriter.Style().Box.PaddingLeft = ""
tableWriter.Style().Box.PaddingRight = " "
tableWriter.Style().Options.DrawBorder = false
tableWriter.Style().Options.SeparateHeader = false
tableWriter.Style().Options.SeparateColumns = false
return tableWriter
}
// filterTableColumns returns configurations to hide columns
// that are not provided in the array. If the array is empty,
// no filtering will occur!
func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
if len(columns) == 0 {
return nil
}
columnConfigs := make([]table.ColumnConfig, 0)
for _, headerTextRaw := range header {
headerText, _ := headerTextRaw.(string)
hidden := true
for _, column := range columns {
if strings.EqualFold(strings.ReplaceAll(column, "_", " "), headerText) {
hidden = false
break
}
}
columnConfigs = append(columnConfigs, table.ColumnConfig{
Name: headerText,
Hidden: hidden,
})
}
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.
//
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
// tag will be used to sort. An error will be returned if no field has this tag.
//
// 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, defaultSort, 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`)
}
if sort == "" {
sort = defaultSort
}
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 "%v"`, 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 "%v"`, column, 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.RFC3339)
case *time.Time:
if val != nil {
v = val.Format(time.RFC3339)
}
case *int64:
if val != nil {
v = *val
}
case fmt.Stringer:
if val != nil {
v = val.String()
}
}
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, defaultSort, recursive bool, skipParentName bool, err error) {
tags, err := structtag.Parse(string(field.Tag))
if err != nil {
return "", false, false, 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, false, false, nil
}
defaultSortOpt := false
recursiveOpt := false
skipParentNameOpt := false
for _, opt := range tag.Options {
switch opt {
case "default_sort":
defaultSortOpt = true
case "recursive":
recursiveOpt = true
case "recursive_inline":
// recursive_inline is a helper to make recursive tables look nicer.
// It skips prefixing the parent name to the child name. If you do this,
// make sure the child name is unique across all nested structs in the parent.
recursiveOpt = true
skipParentNameOpt = true
default:
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
}
}
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, 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, 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{}
defaultSortName := ""
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name, defaultSort, recursive, skip, 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
}
if defaultSort {
if defaultSortName != "" {
return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String())
}
defaultSortName = name
}
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 {
fullName := fmt.Sprintf("%s %s", name, childName)
if skip {
fullName = childName
}
headers = append(headers, fullName)
}
continue
}
headers = append(headers, name)
}
if defaultSortName == "" {
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
}
return headers, defaultSortName, 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, skip, 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 {
fullName := fmt.Sprintf("%s %s", name, childName)
if skip {
fullName = childName
}
row[fullName] = childValue
}
continue
}
// Otherwise, we just use the field value.
row[name] = val.Field(i).Interface()
}
return row, nil
}