1
\$\begingroup\$

A small utility to print data in an ascii table

TextTable

public class TextTable {

    private TextTable(){ }

    public static List<String> getLines(List<TableRow> items){
        return new TextTableFormatter(items).createTableRows();
    }

    public static void print(List<TableRow> items, PrintStream stream){
        getLines(items).forEach(stream::println);
    }

    public static void print(List<TableRow> items){
        print(items, System.out);
    }
}

TableRow

@FunctionalInterface
public interface TableRow {

    Map<String, String> getCellDescriptions();
}

TextTableFormatter

public class TextTableFormatter {

    private final Map<String, Integer> columnsWidths;
    private final Set<String> columnNames;
    private final List<TableRow> items;

    //see https://en.wikipedia.org/wiki/Box-drawing_character for more characters
    private static final String CROSS = "┼";
    private static final String HORIZONTAL_LINE = "─";
    private static final String VERTICAL_LINE = "│";
    private static final String CORNER_TOP_LEFT = "┌";
    private static final String CORNER_TOP_RIGHT = "┐";
    private static final String JOIN_TOP = "┬";
    private static final String JOIN_LEFT = "├";
    private static final String JOIN_RIGHT = "┤";
    private static final String CORNER_BOTTOM_LEFT = "└";
    private static final String CORNER_BOTTOM_RIGHT = "┙";
    private static final String JOIN_BOTTOM = "┴";

    TextTableFormatter(List<TableRow> items) {
        this.items = items;
        columnNames = calculateColumnNames();
        columnsWidths = calculateColumnWidths();
    }

    List<String> createTableRows() {
        List<String> tableRows = new ArrayList<>();

        //header rows
        tableRows.add(createTopTableLine());
        tableRows.add(createHeaderRow());
        tableRows.add(createMiddleTableLine());

        //data rows
        for (int i = 0; i < items.size(); i++) {
            TableRow item = items.get(i);
            tableRows.add(createDataRow(item));
            tableRows.add(isLastItem(i) ? createBottomTableLine() : createMiddleTableLine());
        }
        return tableRows;
    }

    private String createDataRow(TableRow item) {
        StringBuilder itemLine = new StringBuilder();
        for (String columnName : columnNames) {
            String cell = item.getCellDescriptions().getOrDefault(columnName, "");
            itemLine.append(VERTICAL_LINE).append(fillCenter(cell, columnsWidths.get(columnName)));
        }
        itemLine.append(VERTICAL_LINE);
        return itemLine.toString();
    }

    private String createHeaderRow() {
        StringBuilder headerLine = new StringBuilder();
        for (String columnName : columnNames) {
            headerLine.append(VERTICAL_LINE).append(fillCenter(columnName, columnsWidths.get(columnName)));
        }
        headerLine.append(VERTICAL_LINE);
        return headerLine.toString();
    }

    private Map<String, Integer> calculateColumnWidths() {
        Map<String, Integer> columnWidths = new HashMap<>();
        for (String columnName : columnNames) {
            int width = items.stream()
                    .map(i -> i.getCellDescriptions().getOrDefault(columnName, ""))
                    .mapToInt(String::length)
                    .max().orElse(0);
            width = Math.max(width, columnName.length());
            columnWidths.put(columnName, width);
        }
        return columnWidths;
    }

    private Set<String> calculateColumnNames() {
        return items.stream()
                .flatMap(i -> i.getCellDescriptions().keySet().stream())
                .collect(Collectors.toSet());
    }

    private String createTopTableLine() {
        return createTableLine(CORNER_TOP_LEFT, JOIN_TOP, CORNER_TOP_RIGHT);
    }

    private String createMiddleTableLine() {
        return createTableLine(JOIN_LEFT, CROSS, JOIN_RIGHT);
    }

    private String createBottomTableLine() {
        return createTableLine(CORNER_BOTTOM_LEFT, JOIN_BOTTOM, CORNER_BOTTOM_RIGHT);
    }

    private String createTableLine(String startCharacter, String contentSeparator, String endCharacter) {
        return startCharacter + columnNames.stream().map(c -> HORIZONTAL_LINE.repeat(columnsWidths.get(c))).collect(Collectors.joining(contentSeparator)) + endCharacter;
    }

    private String fillCenter(String input, int length) {
        int numberOfMissing = length - input.length();
        int lead = numberOfMissing / 2;
        int trail = numberOfMissing - lead;
        return " ".repeat(lead) + input + (" ".repeat(trail));
    }

    private boolean isLastItem(int i) {
        return i == items.size()-1;
    }

}

TextTableTest

class TextTableTest {

    @Test
    public void testTablePrintOutput(){
        List<TableRow> items = List.of(
                new TablePoint(1,2),
                new TablePoint(1.2, 2.3),
                new Person("peter", "parker"),
                new Person("bruce", "waytoolongname"),
                new NamedPoint(-2.44, 3.1415, "alfred"));
        TextTable.print(items);
    }

    private record TablePoint (double x, double y) implements TableRow {
        @Override
        public Map<String, String> getCellDescriptions() {
            Map<String,String> tableRow = new HashMap<>();
            tableRow.put("x", Double.toString(x));
            tableRow.put("y", Double.toString(y));
            return tableRow;
        }
    }

    private record Person (String name, String familyName) implements TableRow {
        @Override
        public Map<String, String> getCellDescriptions() {
            Map<String,String> tableRow = new HashMap<>();
            tableRow.put("first name", name);
            tableRow.put("family", familyName);
            return tableRow;
        }
    }

    private record NamedPoint (double x, double y, String name) implements TableRow {
        @Override
        public Map<String, String> getCellDescriptions() {
            Map<String,String> tableRow = new HashMap<>();
            tableRow.put("x", Double.toString(x));
            tableRow.put("y", Double.toString(y));
            tableRow.put("first name", name);
            return tableRow;
        }
    }

}

output testcase

┌──────────┬─────┬──────┬──────────────┐
│first name│  x  │  y   │    family    │
├──────────┼─────┼──────┼──────────────┤
│          │ 1.0 │ 2.0  │              │
├──────────┼─────┼──────┼──────────────┤
│          │ 1.2 │ 2.3  │              │
├──────────┼─────┼──────┼──────────────┤
│  peter   │     │      │    parker    │
├──────────┼─────┼──────┼──────────────┤
│  bruce   │     │      │waytoolongname│
├──────────┼─────┼──────┼──────────────┤
│  alfred  │-2.44│3.1415│              │
└──────────┴─────┴──────┴──────────────┙

my challenges so far:

  • i am having trouble with the TableRow interface, i am not sure if that would be the proper approach
  • i am also having trouble with my test cases, how am i supposed to test the output?
  • open/closed, how easy could one extend the utility to provide more functionallity
    (eg. sorted columns names, different fill-blanks-behaviour, or adding a number column)
  • how much sense makes the TextTable class? is this really helpful for simplified access (see tests) or just a middle man antipattern?

of course any feedback is welcome!

note:

this code is not yet optimized, since the amount of data should be read by humans

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

I'll start with: I mostly like what you've done.

TableRow I think can be fine as is given your example usage and even advantageous if you have more complicated classes you want to add TableRow to. Since you hide getting the rows behind a function, you have control over the lifetime and caching of the underlying map if building it is expensive.

If your current approach feels repetitive because these records are just going to be simple, you do have some options. An alternative might be making a factory class with static functions like private TableRow buildPerson(String name, String, familyName) which can put all that code in one place. In any scenario, you could also consider typedef'ing Map<String,String> to TableRow directly which is more favoring "composition over inheritance" when handling it.

Testing around this I think is straightforward giving your small public interface: Something that takes an object conforming to TableRow (which produces a Map) and gives back a list of strings.

In general, when thinking about where to test, I usually start very broadly and work my way in to what may be practical (sometimes these are the same). In your case, that would mean starting with matching the list of full-table text output itself. If you wanted to focus on output tokens, you have a pretty regular structure where you could write some helper code to parse the table rows and columns and look at the output tokens. I've seen for larger text outputs to have sample tables stored as text files and directly comparing the whole output. That's good for comparing against known snaphots.

Narrowing in on tests I'm not sure I would consider going past that boundary for functionality. This is nice because you leave yourself a lot of room for refactoring. I'd just make sure I cover enough cases where you have a variety of different inputs. You could make your existing test a bit more simple by just starting with one type (like just Person), then the other, and then both which would be a bit more robust than all at once.

I think for open/closed, you've done pretty well given a Map is very flexible for your case. You are going to break this and have to change or extend TextTableFormatter in its current form if you want stuff like sorting and numbered columns.

I'd consider making an abstract base class or interface exposing createTableRows and the abstract base class having your character definitions (this may also be good to pull out into it's only class/namespace that you can reference in to keep things cleaner) and other common code you identify. Once you have that interface or base to build around, you can make variations of TextTableFormatter like SortedTextTableFormatter maybe where the constructor takes in some sorting options so everyone can keep the same createTableRows signature. I'd have to stumble through concrete examples before I could comment on how reusing code via inheritance might be optimized, but I'm sure there's opportunities there.

Your TextTable class is effectively a collection of helper functions since everything is static. I tend to go pretty sparse on these since they can quickly turn into a junk drawer. Since you're only using them in the test, I would have just added them as private functions to the test class (or at least in a separate helper class in the tests directory) to keep them where they're used. If it's not production code and not intended to be used by whomever is consuming this package I'd avoid putting it with the stuff that is.

\$\endgroup\$
1
  • \$\begingroup\$ thank you very much for your review - i am still struggeling on how to test properly the structure of the table, but i think i found a way. Thank you for sharing your thoughs about TableRow - i think a factory might be a good way to go... \$\endgroup\$ Commented Mar 21, 2024 at 9:17
2
\$\begingroup\$

I don't really have a code review. What I have is more of an idea.

Possum suggests that you can use a factory. However, I don't think a factory is particularly suitable for this use case. Because the more columns your table gets, the more confusing the creation of a TableRow becomes. Even with just three values, it can become confusing: new NamedPoint(-2.44, 3.1415, "alfred"). Is "alfred" the first name or the surname? Have I perhaps entered -2.44 and 3.1415 the wrong way round? To check this, I have to look into the constructor and check which argument belongs to which construct parameter depending on the position. This is still feasible with three values, but with 10 values it could be exhausting to check whether the values are specified correctly.

This is the reason I would instead recommend a builder:

var table = Table.builder(MartinsExampleRow::new)
 .add(row -> row.x(1).y(2))
 .add(row -> row.x(1.2).y(2.3))
 .add(row -> row.firstName("peter").lastName("parker"))
 .add(row -> row.firstName("bruce").lastName("waytoolongname"))
 .add(row -> row.x(-2.44).y(3.1415).firstName("alfred"))
 .build();

There are actually two builders. One builder for the table and one builder for the row. The builder for the row e.g. row.x(-2.44).y(3.1415).firstName("alfred") allows us to see immediately whether we have entered the values correctly. We do not have to check the constructor.


public class Table {
    private final List<TableRow> rows;

    private Table(List<TableRow> rows) {
        this.rows = rows;
    }

    public void print() {
        new TextTableFormatter(rows).createTableRows().forEach(System.out::println);
    }

    public static class Builder<T extends RowBuilder> {
        private final List<TableRow> rows = new ArrayList<>();
        private final Supplier<T> supplier;

        public Builder(Supplier<T> supplier) {
            this.supplier = supplier;
        }

        public Builder<T> add(Consumer<T> row) {
            row.andThen(x -> rows.add(x.build())).accept(supplier.get());
            return this;
        }

        public Table build() {
            return new Table(rows);
        }
    }

    public interface RowBuilder {
        TableRow build();
    }
}
class MartinsExampleRow implements Table.RowBuilder {
    private String firstName = "";
    private String lastName = "";
    private double x;
    private double y;

    public MartinsExampleRow firstName(String value) {
        this.firstName = value;
        return this;
    }

    public MartinsExampleRow lastName(String value) {
        this.lastName = value;
        return this;
    }

    public MartinsExampleRow x(double value) {
        this.x = value;
        return this;
    }

    public MartinsExampleRow y(double value) {
        this.y = value;
        return this;
    }

    @Override
    public TableRow build() {
        return () -> Map.of(
                "first name", firstName,
                "family", lastName,
                "x", Double.toString(x),
                "y", Double.toString(y)
        );
    }
}

public class TextTableTest {
    public static void main(String[] args) {
        new TextTableTest().testTablePrintOutput();
    }

    public void testTablePrintOutput() {
        Table table = new Table.Builder<>(MartinsExampleRow::new)
                .add(row -> row.x(1).y(2))
                .add(row -> row.x(1.2).y(2.3))
                .add(row -> row.firstName("peter").lastName("parker"))
                .add(row -> row.firstName("bruce").lastName("waytoolongname"))
                .add(row -> row.x(-2.44).y(3.1415).firstName("alfred"))
                .build();

        table.print();
    }
}
\$\endgroup\$
1
  • \$\begingroup\$ you put your finger on a very good point, the data creation is not as straight forward as i hoped for... Since i am having trouble with the TableRow interface, i am not sure if that would be the proper approach? Well - your approach seems legit as well! (as well as the idea of @possum) \$\endgroup\$ Commented Mar 25, 2024 at 6:12

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.