Transformation: Format the Table

All that’s left from our design is to create the formatted table. The following would be a nice interface:

 def​ process({user, project, count}) ​do
  Issues.GithubIssues.fetch(user, project)
  |> decode_response()
  |> sort_into_ascending_order()
  |> last(count)
» |> print_table_for_columns([​"​​number"​, ​"​​created_at"​, ​"​​title"​])
 end

We pass the formatter the list of columns to include in the table, and it writes the table to standard output. The formatter doesn’t add any new project- or design-related techniques, so we’ll just show the listing.

project/4/issues/lib/issues/table_formatter.ex
 defmodule​ Issues.TableFormatter ​do
 
 import​ Enum, ​only:​ [ ​each:​ 2, ​map:​ 2, ​map_join:​ 3, ​max:​ 1 ]
 
 def​ print_table_for_columns(rows, headers) ​do
 with​ data_by_columns = split_into_columns(rows, headers),
  column_widths = widths_of(data_by_columns),
  format = format_for(column_widths)
 do
  puts_one_line_in_columns(headers, format)
  IO.puts(separator(column_widths))
  puts_in_columns(data_by_columns, format)
 end
 end
 
 def​ split_into_columns(rows, headers) ​do
  for header <- headers ​do
  for row <- rows, ​do​: printable(row[header])
 end
 end
 
 def​ printable(str) ​when​ is_binary(str), ​do​: str
 def​ printable(str), ​do​: to_string(str)
 
 def​ widths_of(columns) ​do
  for column <- columns, ​do​: column |> map(&String.length/1) |> max
 end
 
 def​ format_for(column_widths) ​do
  map_join(column_widths, ​"​​ | "​, ​fn​ width -> ​"​​~-​​#{​width​}​​s"​ ​end​) <> ​"​​~n"
 end
 
 def​ separator(column_widths) ​do
  map_join(column_widths, ​"​​-+-"​, ​fn​ width -> List.duplicate(​"​​-"​, width) ​end​)
 end
 
 def​ puts_in_columns(data_by_columns, format) ​do
  data_by_columns
  |> List.zip
  |> map(&Tuple.to_list/1)
  |> each(&puts_one_line_in_columns(&1, format))
 end
 
 def​ puts_one_line_in_columns(fields, format) ​do
 :io​.format(format, fields)
 end
 end

And here are the tests for it:

project/4/issues/test/table_formatter_test.exs
 defmodule​ TableFormatterTest ​do
 use​ ExUnit.Case ​# bring in the test functionality
 import​ ExUnit.CaptureIO ​# And allow us to capture stuff sent to stdout
 
  alias Issues.TableFormatter, ​as:​ TF
 
  @simple_test_data [
  [ ​c1:​ ​"​​r1 c1"​, ​c2:​ ​"​​r1 c2"​, ​c3:​ ​"​​r1 c3"​, ​c4:​ ​"​​r1+++c4"​ ],
  [ ​c1:​ ​"​​r2 c1"​, ​c2:​ ​"​​r2 c2"​, ​c3:​ ​"​​r2 c3"​, ​c4:​ ​"​​r2 c4"​ ],
  [ ​c1:​ ​"​​r3 c1"​, ​c2:​ ​"​​r3 c2"​, ​c3:​ ​"​​r3 c3"​, ​c4:​ ​"​​r3 c4"​ ],
  [ ​c1:​ ​"​​r4 c1"​, ​c2:​ ​"​​r4++c2"​, ​c3:​ ​"​​r4 c3"​, ​c4:​ ​"​​r4 c4"​ ]
  ]
 
  @headers [ ​:c1​, ​:c2​, ​:c4​ ]
 
 def​ split_with_three_columns ​do
  TF.split_into_columns(@simple_test_data, @headers)
 end
 
  test ​"​​split_into_columns"​ ​do
  columns = split_with_three_columns()
  assert length(columns) == length(@headers)
  assert List.first(columns) == [​"​​r1 c1"​, ​"​​r2 c1"​, ​"​​r3 c1"​, ​"​​r4 c1"​]
  assert List.last(columns) == [​"​​r1+++c4"​, ​"​​r2 c4"​, ​"​​r3 c4"​, ​"​​r4 c4"​]
 end
 
  test ​"​​column_widths"​ ​do
  widths = TF.widths_of(split_with_three_columns())
  assert widths == [ 5, 6, 7 ]
 end
 
  test ​"​​correct format string returned"​ ​do
  assert TF.format_for([9, 10, 11]) == ​"​​~-9s | ~-10s | ~-11s~n"
 end
 
 
  test ​"​​Output is correct"​ ​do
  result = capture_io ​fn​ ->
  TF.print_table_for_columns(@simple_test_data, @headers)
 end
  assert result == ​"""
  c1 | c2 | c4
  ------+--------+--------
  r1 c1 | r1 c2 | r1+++c4
  r2 c1 | r2 c2 | r2 c4
  r3 c1 | r3 c2 | r3 c4
  r4 c1 | r4++c2 | r4 c4
  """
 end
 end

(Although you can’t see it here, the output we compare against in the last test contains trailing whitespace.)

Rather than clutter the process function in the CLI module with a long module name, I chose to use import to make the print function available without a module qualifier. This goes near the top of cli.ex.

 defmodule​ Issues.CLI ​do
 
 import​ Issues.TableFormatter, ​only:​ [ ​print_table_for_columns:​ 2 ]

This code also uses a great Elixir testing feature. By importing ExUnit.CaptureIO, we get access to the capture_io function. This runs the code passed to it but captures anything written to standard output, returning it as a string.