☁ Problem
Printing some data to the CLI, as a table, usually requires one of the following
- fiddling with spaces and tabs to evenly align data and dealing with various edge cases, long strings etc.
- using a third-party library, which can have the benefit of nice-looking results, but at the cost of bringing in an extra-dependency just for that
⚑ Goal
- Printing tables to the CLI without fiddling with spaces, tabs etc. AND without using a third-party library.
☂ Solution
Use the test/tabwriter
package provided by the Go standard library and create a custom function around it that suits almost any scenario that doesn't require sophisticated output.
☵ Example
Boilerplate
Create a folder named print-cli-tables
, cd
into it and initialize it as a Go module by running go mod init print-cli-tables
.
Then create the following tree of folders and files:
printer
- table_printer.go
main.go
Code
printer/table_printer.go
package printer
import (
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
)
// PrintTable ...
func PrintTable(
w io.Writer,
caption string,
cols []string,
getNextRows func() (bool, [][]string),
) {
nbCols := len(cols)
if nbCols == 0 {
return
}
if len(caption) > 0 {
fmt.Fprintf(w, "%s:\n\n", caption)
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
colSep := "\t"
header := append([]string{"#"}, cols...)
fmt.Fprintf(tw, "%s%s\n", strings.Join(header, colSep), colSep)
var sb strings.Builder
i := 1
hasMore, rows := getNextRows()
for hasMore {
for iRow, row := range rows {
nbRowCols := len(row)
for j := 0; j < nbCols; j++ {
if j < nbRowCols {
sb.WriteString(row[j])
}
sb.WriteString(colSep)
}
iRowStr := ""
if iRow == 0 {
iRowStr = strconv.Itoa(i)
}
fmt.Fprintf(tw, "%s%s%s\n", iRowStr, colSep, sb.String())
sb.Reset()
}
i++
hasMore, rows = getNextRows()
}
_ = tw.Flush()
}
main.go
package main
import (
"fmt"
"print-cli-tables/printer"
"strings"
)
type permission struct {
database string
permission string
tablePermissions map[string]string
}
type user struct {
username string
email string
permissions []*permission
}
var users = []*user{
&user{
username: "user1",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "db1",
tablePermissions: map[string]string{
"table1": "RW",
"table2": "R",
},
},
&permission{
database: "db2",
permission: "admin",
},
},
},
&user{
username: "user2",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "db2",
permission: "RW",
},
&permission{
database: "db3",
tablePermissions: map[string]string{
"table3": "R",
},
},
},
},
&user{
username: "superadmin",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "*",
permission: "admin",
},
},
},
&user{
username: "inactiveuser",
email: "[email protected]",
},
}
func main() {
cols := []string{"User", "Email", "Database", "Table", "Permissions"}
i := 0
strBuilder := &strings.Builder{}
printer.PrintTable(
strBuilder, // or just pass os.Stdout
fmt.Sprintf("%d user(s)", len(users)),
cols,
func() (bool, [][]string) {
if len(users)-1 < i {
return false, nil
}
u := users[i]
i++
return true, userToRows(u)
})
fmt.Print(strBuilder.String())
}
func userToRows(u *user) [][]string {
var rows [][]string
if len(u.permissions) == 0 {
return [][]string{[]string{u.username, u.email, "-", "-", "-"}}
}
rows = make([][]string, 0, len(u.permissions))
for _, ps := range u.permissions {
if len(ps.tablePermissions) == 0 {
rows = append(rows, []string{"", "", ps.database, "*", ps.permission})
continue
}
first := false
for t, p := range ps.tablePermissions {
row := []string{"", "", "", t, p}
if first == false {
row[2] = ps.database
first = true
}
rows = append(rows, row)
}
}
rows[0][0] = u.username
rows[0][1] = u.email
return rows
}
Reading and running the code
In the code above one can observe that the PrintTable
function is designed so that it takes the following params:
- An
io.Writer
which can be, for example,os.Stdout
if one wishes to print directly to the CLI orstrings.Builder
if one wants to do something else with the output. - A caption for the table
- The table heading (i.e. column names)
- A callback function which returns a
bool
indicating whether there are more rows or not, a two-dimensionalarray
ofstring
s which holds the next row(s). The reason for the two-dimensionality of it is that one might need to print multiple rows for a single data item, as can be seen in the example code and data frommain.go
where theuserToRows
function returns more than one row for one user (i.e. multiple permissions for databases and tables).
💡 Basically the PrintTable
function only deals with arrays of string
s delegating to the caller the responsibility of converting any data it has and also the one of deciding when the table ends (e.g. by keeping a counter while feeding the data through the callback).
Running the code will show the following output:
➤ go run main.go
4 user(s):
# User Email Database Table Permissions
1 user1 [email protected] db1 table1 RW
table2 R
db2 * admin
2 user2 [email protected] db2 * RW
db3 table3 R
3 superadmin [email protected] * * admin
4 inactiveuser [email protected] - - -