4
\$\begingroup\$

About six years ago I implemented a simple tabular pretty-print Java class that mostly simulated MySQL CLI query result tables. I don't really like it because it was implemented in a, I believe, pretty dirty manner being not extensible, and I would assume that the same thing might be implemented using Java 8 Stream API that might be a bit more generic. The following implementation is just a matter of reimplementation interest and I'm trying to learn writing some Spliterators.

The source code:

public final class Table {

    private Table() {
    }

    public static Collector<String[], ?, Stream<String>> toTable() {
        return Collector.of(
                State::new,
                State::addRow,
                State::mergeState,
                State::stream
        );
    }

    public static Collector<String[], ?, Stream<String>> toTable(final String... columnHeaders) {
        return Collector.of(
                () -> {
                    final State state = new State();
                    state.addRow(columnHeaders);
                    return state;
                },
                State::addRow,
                State::mergeState,
                State::stream
        );
    }

    private static final class State {

        private static final int[] emptyIntArray = {};

        private final Collection<String[]> rows = new ArrayList<>();

        private int[] maxColumnWidths = emptyIntArray;

        private State() {
        }

        private void addRow(final String... values) {
            ensureColumnMaxLengths(values);
            recalculateWidths(values);
            rows.add(values);
        }

        private State mergeState(final State stateR) {
            for ( final String[] row : stateR.rows ) {
                addRow(row);
            }
            return this;
        }

        private void ensureColumnMaxLengths(final String... values) {
            if ( maxColumnWidths.length < values.length ) {
                final int[] newWidths = new int[values.length];
                System.arraycopy(maxColumnWidths, 0, newWidths, 0, maxColumnWidths.length);
                maxColumnWidths = newWidths;
            }
        }

        private void recalculateWidths(final String... values) {
            final int count = values.length;
            for ( int i = 0; i < count; i++ ) {
                final String value = values[i];
                final int valueLength = value.length();
                if ( valueLength > maxColumnWidths[i] ) {
                    maxColumnWidths[i] = valueLength;
                }
            }
        }

        private Stream<String> stream() {
            return StreamSupport.stream(FormattedRowSpliterator.get(rows, maxColumnWidths), false);
        }


    }

    private static final class FormattedRowSpliterator
            implements Spliterator<String> {

        private static final char KNOT = '+';
        private static final char VERTICAL = '|';
        private static final char HORIZONTAL = '-';
        private static final char EMPTY = ' ';

        private enum State {

            BEFORE_ROW,
            AT_ROW,
            NO_MORE_ROWS

        }

        private final int estimatedSize;
        private final Iterator<String[]> rowsIterator;
        private final int[] maxColumnWidths;

        private State state;

        @Nullable
        private String border;

        private FormattedRowSpliterator(final int estimatedSize, final Iterator<String[]> rowsIterator, final int[] maxColumnWidths) {
            this.estimatedSize = estimatedSize;
            this.rowsIterator = rowsIterator;
            this.maxColumnWidths = maxColumnWidths;
            state = rowsIterator.hasNext() ? State.BEFORE_ROW : State.NO_MORE_ROWS;
        }

        private static Spliterator<String> get(final Collection<String[]> collection, final int[] maxColumnWidths) {
            final Iterator<String[]> iterator = collection.iterator();
            return new FormattedRowSpliterator(collection.size() * 2 + 1, iterator, maxColumnWidths);
        }

        @Override
        public boolean tryAdvance(final Consumer<? super String> action) {
            switch ( state ) {
            case BEFORE_ROW:
                renderBorder(action);
                state = State.AT_ROW;
                return true;
            case AT_ROW:
                final String[] values = rowsIterator.next();
                renderRow(values, action);
                state = rowsIterator.hasNext() ? State.BEFORE_ROW : State.NO_MORE_ROWS;
                return true;
            case NO_MORE_ROWS:
                renderBorder(action);
                return false;
            default:
                throw new AssertionError(state);
            }
        }

        @Override
        @Nullable
        public Spliterator<String> trySplit() {
            return null;
        }

        @Override
        public long estimateSize() {
            return estimatedSize;
        }

        @Override
        public int characteristics() {
            return Spliterator.IMMUTABLE | Spliterator.NONNULL /*| Spliterator.ORDERED*/ | Spliterator.SIZED;
        }

        private void renderBorder(final Consumer<? super String> action) {
            if ( border == null ) {
                final StringBuilder borderBuilder = new StringBuilder();
                borderBuilder.append(KNOT);
                for ( final int columnMaxLength : maxColumnWidths ) {
                    for ( int i = 0; i < columnMaxLength; i++ ) {
                        borderBuilder.append(HORIZONTAL);
                    }
                    borderBuilder.append(KNOT);
                }
                border = borderBuilder.toString();
            }
            action.accept(border);
        }

        private void renderRow(final String[] values, final Consumer<? super String> action) {
            assert border != null;
            final StringBuilder rowBuilder = new StringBuilder(border.length());
            renderRowValues(values, rowBuilder);
            renderRowMissingValues(rowBuilder, values.length);
            final String row = rowBuilder.toString();
            action.accept(row);
        }

        private void renderRowValues(final String[] values, final StringBuilder rowBuilder) {
            rowBuilder.append(VERTICAL);
            for ( int i = 0; i < values.length; i++ ) {
                final String value = values[i];
                rowBuilder.append(value);
                final int valueLength = value.length();
                for ( int j = valueLength; j < maxColumnWidths[i]; j++ ) {
                    rowBuilder.append(EMPTY);
                }
                rowBuilder.append(VERTICAL);
            }
        }

        private void renderRowMissingValues(final StringBuilder rowBuilder, final int valueCount) {
            if ( valueCount == 0 ) {
                rowBuilder.append(VERTICAL);
            }
            for ( int i = valueCount; i < maxColumnWidths.length; i++ ) {
                for ( int j = 0; j < maxColumnWidths[i]; j++ ) {
                    rowBuilder.append(EMPTY);
                }
                rowBuilder.append(VERTICAL);
            }
        }

    }

}

Example of use:

public static void main(final String... args) {
    Stream.generate(() -> new int[]{ random(), random(), random() })
            .limit(5)
            .map(row -> new String[]{ Integer.toString(row[0]), Integer.toString(row[1]), Integer.toString(row[2]) })
            .collect(Table.toTable("value 1"))
            .forEach(System.out::println);
}

private static int random() {
    final int digit = (int) (Math.random() * 10);
    return (int) Math.pow(10, digit - 1) * digit;
}

Example result:

+-------+---------+--------+
|value 1|value 2  |value 3 |
+-------+---------+--------+
|300    |900000000|50000   |
+-------+---------+--------+
|600000 |900000000|80000000|
+-------+---------+--------+
|20     |900000000|80000000|
+-------+---------+--------+
|4000   |7000000  |4000    |
+-------+---------+--------+
|1      |20       |80000000|
+-------+---------+--------+

Known limitations:

  • The implementation does not deal with nulls by design (and I do believe this is fine, however the code above does not check for input nulls and may throw "obscure" exceptions).
  • This implementation is not designed to be extensible (for example, its design is not really suitable for CSV streams since the current tabular representation must scan the whole table in advance whereas CSV does not require it and may transform/render infinite data streams).

My concerns at least are:

  1. This implementation renders the formatted strings and table borders as whole strings. Probably rendering the each cell separately might be a better or more optimal idea, but I don't know how it would affect the \n character then.
  2. I don't know how fine is the implemented spliterator, especially its characteristics() method.
  3. I'm not 100% sure if dynamic expanding the maxColumnWidths array is a good idea. For example, I would assume that the collector must only expect well-formed data, and detecting jagged rows is basically out of the scope of the collector (simply speaking, it probably should always expect for n x m matrices).
  4. Well, a collector that produces a stream.

Any corrections and suggestions are welcome. Thank you.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.