diff --git a/cypher-shell/src/main/java/org/neo4j/shell/prettyprint/TablePlanFormatter.java b/cypher-shell/src/main/java/org/neo4j/shell/prettyprint/TablePlanFormatter.java index 57736bf1..a1074378 100644 --- a/cypher-shell/src/main/java/org/neo4j/shell/prettyprint/TablePlanFormatter.java +++ b/cypher-shell/src/main/java/org/neo4j/shell/prettyprint/TablePlanFormatter.java @@ -56,16 +56,16 @@ private int width(@Nonnull String header, @Nonnull Map columns) return 2 + Math.max(header.length(), columns.get(header)); } - private void pad(int width, char chr, @Nonnull StringBuilder result) { + private static void pad(int width, char chr, @Nonnull StringBuilder result) { result.append(OutputFormatter.repeat(chr, width)); } - private void divider(@Nonnull List headers, @Nullable Line line /*= null*/, @Nonnull StringBuilder result, @Nonnull Map columns) { + private void divider(@Nonnull List headers, @Nullable TableRow tableRow /*= null*/, @Nonnull StringBuilder result, @Nonnull Map columns) { for (String header : headers) { - if (line != null && header.equals(OPERATOR) && line.connection.isPresent()) { + if (tableRow != null && header.equals(OPERATOR) && tableRow.connection.isPresent()) { result.append("|"); - String connection = line.connection.get(); + String connection = tableRow.connection.get(); result.append(" ").append(connection); pad(width(header, columns) - connection.length() - 1, ' ', result); } else { @@ -79,33 +79,34 @@ private void divider(@Nonnull List headers, @Nullable Line line /*= null @Nonnull String formatPlan(@Nonnull Plan plan) { Map columns = new HashMap<>(); - List lines = accumulate(plan, new Root(), columns); + List tableRows = accumulate(plan, new Root(), columns); // Remove Identifiers column if we have a Details column List headers = HEADERS.stream().filter(header -> columns.containsKey(header) && !(header.equals(IDENTIFIERS) && columns.containsKey(DETAILS))).collect(Collectors.toList()); - StringBuilder result = new StringBuilder((2 + NEWLINE.length() + headers.stream().mapToInt(h -> width(h, columns)).sum()) * (lines.size() * 2 + 3)); - - List allLines = new ArrayList<>(); - Map headerMap = headers.stream().map(header -> Pair.of(header, new Left(header))).collect(toMap(p -> p._1, p -> p._2)); - allLines.add(new Line(OPERATOR, headerMap, Optional.empty())); - allLines.addAll(lines); - for (Line line : allLines) { - divider(headers, line, result, columns); - for (String header : headers) { - Justified detail = line.get(header); - result.append("| "); - if (detail instanceof Left) { - result.append(detail.text); - pad(width(header, columns) - detail.length - 2, ' ', result); + StringBuilder result = new StringBuilder((2 + NEWLINE.length() + headers.stream().mapToInt(h -> width(h, columns)).sum()) * (tableRows.size() * 2 + 3)); + + List allTableRows = new ArrayList<>(); + Map headerMap = headers.stream().map(header -> Pair.of(header, new LeftJustifiedCell(header))).collect(toMap(p -> p._1, p -> p._2)); + allTableRows.add(new TableRow(OPERATOR, headerMap, Optional.empty())); + allTableRows.addAll( tableRows ); + for (int rowIndex = 0; rowIndex < allTableRows.size(); rowIndex++) { + TableRow tableRow = allTableRows.get( rowIndex); + divider(headers, tableRow, result, columns); + for (int rowLineIndex = 0; rowLineIndex < tableRow.height; rowLineIndex++) { + for (String header : headers) { + Cell cell = tableRow.get(header); + String defaultText = ""; + if (header.equals(OPERATOR) && rowIndex + 1 < allTableRows.size()) { + defaultText = allTableRows.get(rowIndex + 1).connection.orElse("").replace('\\', ' '); + } + result.append( "| " ); + int columnWidth = width(header, columns); + cell.writePaddedLine(rowLineIndex, defaultText, columnWidth, result); + result.append( " " ); } - if (detail instanceof Right) { - pad(width(header, columns) - detail.length - 2, ' ', result); - result.append(detail.text); - } - result.append(" "); + result.append("|").append(NEWLINE); } - result.append("|").append(NEWLINE); } divider(headers, null, result, columns); @@ -171,7 +172,7 @@ private String serialize(@Nonnull String key, @Nonnull Value v) { } } - @Nonnull private Stream> children(@Nonnull Plan plan, Level level,@Nonnull Map columns) { + @Nonnull private Stream> children(@Nonnull Plan plan, Level level, @Nonnull Map columns) { List c = plan.children(); switch (c.size()) { case 0: @@ -184,54 +185,55 @@ private String serialize(@Nonnull String key, @Nonnull Value v) { throw new IllegalStateException("Plan has more than 2 children " + c); } - @Nonnull private List accumulate(@Nonnull Plan plan, @Nonnull Level level, @Nonnull Map columns) { + @Nonnull private List accumulate(@Nonnull Plan plan, @Nonnull Level level, @Nonnull Map columns) { String line = level.line() + plan.operatorType(); // wa plan.name - mapping(OPERATOR, new Left(line), columns); + mapping(OPERATOR, new LeftJustifiedCell(line), columns); return Stream.concat( - Stream.of(new Line(line, details(plan, columns), level.connector())), + Stream.of(new TableRow(line, details(plan, columns), level.connector())), children(plan, level, columns).flatMap(Collection::stream)) .collect(Collectors.toList()); } @Nonnull - private Map details(@Nonnull Plan plan, @Nonnull Map columns) { + private Map details(@Nonnull Plan plan, @Nonnull Map columns) { Map args = plan.arguments(); - Stream>> formattedPlan = args.entrySet().stream().map((e) -> { + Stream>> formattedPlan = args.entrySet().stream().map(e -> { Value value = e.getValue(); switch (e.getKey()) { case "EstimatedRows": - return mapping(ESTIMATED_ROWS, new Right(format(value.asDouble())), columns); + return mapping(ESTIMATED_ROWS, new RightJustifiedCell(format(value.asDouble())), columns); case "Rows": - return mapping(ROWS, new Right(value.asNumber().toString()), columns); + return mapping(ROWS, new RightJustifiedCell(value.asNumber().toString()), columns); case "DbHits": - return mapping(HITS, new Right(value.asNumber().toString()), columns); + return mapping(HITS, new RightJustifiedCell(value.asNumber().toString()), columns); case "PageCacheHits": - return mapping(PAGE_CACHE, new Right(String.format("%s/%s",value.asNumber(),args.getOrDefault("PageCacheMisses", ZERO_VALUE).asNumber())), columns); + return mapping(PAGE_CACHE, new RightJustifiedCell(String.format("%s/%s", value.asNumber(), args.getOrDefault("PageCacheMisses", ZERO_VALUE).asNumber())), columns); case "Time": - return mapping(TIME, new Right(String.format("%.3f", value.asLong() / 1000000.0d)), columns); + return mapping(TIME, new RightJustifiedCell(String.format("%.3f", value.asLong() / 1000000.0d)), columns); case "Order": - return mapping( ORDER, new Left( String.format( "%s", value.asString() ) ), columns ); + return mapping(ORDER, new LeftJustifiedCell(String.format("%s", value.asString())), columns); case "Details": - return mapping( DETAILS, new Left( truncate(value.asString()) ), columns ); + return mapping(DETAILS, new LeftJustifiedCell(splitDetails(value.asString())), columns); case "Memory": - return mapping( MEMORY, new Right( String.format( "%s", value.asNumber().toString() ) ), columns ); + return mapping(MEMORY, new RightJustifiedCell(String.format("%s", value.asNumber().toString())), columns); default: return Optional.empty(); } }); + return Stream.concat( formattedPlan, Stream.of( - Optional.of(Pair.of(IDENTIFIERS, new Left(identifiers(plan, columns)))), - Optional.of(Pair.of(OTHER, new Left(other(plan, columns)))))) - .filter(Optional::isPresent) - .collect(toMap(o -> o.get()._1, o -> o.get()._2)); + Optional.of(Pair.of(IDENTIFIERS, new LeftJustifiedCell(identifiers(plan, columns)))), + Optional.of(Pair.of(OTHER, new LeftJustifiedCell(other(plan, columns)))))) + .filter(Optional::isPresent) + .collect(toMap(o -> o.get()._1, o -> o.get()._2)); } @Nonnull - private Optional> mapping(@Nonnull String key, @Nonnull Justified value, @Nonnull Map columns) { + private Optional> mapping(@Nonnull String key, @Nonnull Cell value, @Nonnull Map columns) { update(columns, key, value.length); return Optional.of(Pair.of(key, value)); } @@ -285,46 +287,75 @@ private String format(@Nonnull Double v) { return String.valueOf(Math.round(v)); } - - static class Line { - + static class TableRow { private final String tree; - private final Map details; + private final Map cells; private final Optional connection; + private final int height; - Line(String tree, Map details, Optional connection) { + TableRow(String tree, Map cells, Optional connection) { this.tree = tree; - this.details = details; + this.cells = cells; this.connection = connection == null ? Optional.empty() : connection; + this.height = cells.values().stream().mapToInt(v -> v.lines.length).max().orElse(0); } - Justified get(String key) { - if (key.equals(TablePlanFormatter.OPERATOR)) { - return new Left(tree); - } else - return details.getOrDefault(key, new Left("")); + Cell get(String key) { + if (key.equals(TablePlanFormatter.OPERATOR)) + return new LeftJustifiedCell(tree); + else + return cells.getOrDefault(key, new LeftJustifiedCell("")); } } - static abstract class Justified { + static abstract class Cell { final int length; - final String text; + final String[] lines; + + Cell(String[] lines) { + this.length = Stream.of(lines).mapToInt(String::length).max().orElse(0); + this.lines = lines; + } + + abstract void writePaddedLine(int lineIndex, String orElseValue, int columnWidth, StringBuilder result); + + protected int paddingWidth(int columnWidth, String line) { + return columnWidth - line.length() - 2; + } - Justified(String text) { - this.length = text.length(); - this.text = text; + protected String getLineOrElse(int lineIndex, String orElseValue) { + if (lineIndex < lines.length) + return lines[lineIndex]; + else + return orElseValue; } } - static class Left extends Justified { - Left(String text) { - super(text); + static class LeftJustifiedCell extends Cell + { + LeftJustifiedCell(String... lines) { + super(lines); + } + + @Override + void writePaddedLine(int lineIndex, String orElseValue, int columnWidth, StringBuilder result) { + String line = getLineOrElse(lineIndex, orElseValue); + result.append(line); + pad(paddingWidth(columnWidth, line), ' ', result); } } - static class Right extends Justified { - Right(String text) { - super(text); + static class RightJustifiedCell extends Cell { + RightJustifiedCell(String... lines) { + super(lines); + } + + @Override + void writePaddedLine(int lineIndex, String orElseValue, int columnWidth, StringBuilder result) + { + String line = getLineOrElse(lineIndex, orElseValue); + pad(paddingWidth(columnWidth, line), ' ', result); + result.append(line); } } @@ -447,11 +478,16 @@ public static Pair of(T1 _1, T2 _2) { } } - private String truncate( String original ) { - if(original.length() <= MAX_DETAILS_COLUMN_WIDTH){ - return original; + private String[] splitDetails(String original) { + List detailsList = new ArrayList<>(); + + int currentPos = 0; + while(currentPos < original.length()){ + int newPos = Math.min(original.length(), currentPos + MAX_DETAILS_COLUMN_WIDTH); + detailsList.add(original.substring( currentPos, newPos)); + currentPos = newPos; } - return original.substring( 0, MAX_DETAILS_COLUMN_WIDTH - 3 ) + "..."; + return detailsList.toArray(new String[0]); } } diff --git a/cypher-shell/src/test/java/org/neo4j/shell/prettyprint/TablePlanFormatterTest.java b/cypher-shell/src/test/java/org/neo4j/shell/prettyprint/TablePlanFormatterTest.java index d0d954a8..dffc4932 100644 --- a/cypher-shell/src/test/java/org/neo4j/shell/prettyprint/TablePlanFormatterTest.java +++ b/cypher-shell/src/test/java/org/neo4j/shell/prettyprint/TablePlanFormatterTest.java @@ -2,16 +2,20 @@ import org.junit.Test; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import org.neo4j.driver.Value; +import org.neo4j.driver.internal.value.FloatValue; import org.neo4j.driver.internal.value.StringValue; import org.neo4j.driver.summary.Plan; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.neo4j.shell.prettyprint.OutputFormatter.NEWLINE; @@ -20,6 +24,36 @@ public class TablePlanFormatterTest { TablePlanFormatter tablePlanFormatter = new TablePlanFormatter(); + @Test + public void withNoDetails() { + Plan plan = mock(Plan.class); + Map args = Collections.singletonMap( "EstimatedRows", new FloatValue(55)); + when(plan.arguments()).thenReturn(args); + when(plan.operatorType()).thenReturn("Projection"); + + assertThat(tablePlanFormatter.formatPlan( plan ), is(String.join(NEWLINE, + "+-------------+----------------+", + "| Operator | Estimated Rows |", + "+-------------+----------------+", + "| +Projection | 55 |", + "+-------------+----------------+", ""))); + } + + @Test + public void withEmptyDetails() { + Plan plan = mock(Plan.class); + Map args = new HashMap(2){{put("EstimatedRows", new FloatValue(55));put("Details", new StringValue(""));}}; + when(plan.arguments()).thenReturn(args); + when(plan.operatorType()).thenReturn("Projection"); + + assertThat(tablePlanFormatter.formatPlan( plan ), is(String.join(NEWLINE, + "+-------------+---------+----------------+", + "| Operator | Details | Estimated Rows |", + "+-------------+---------+----------------+", + "| +Projection | | 55 |", + "+-------------+---------+----------------+", ""))); + } + @Test public void renderShortDetails() { Plan plan = mock(Plan.class); @@ -47,14 +81,45 @@ public void renderExactMaxLengthDetails() { } @Test - public void truncateTooLongDetails() { + public void multiLineDetails() { + Plan argumentPlan = mock(Plan.class); + when(argumentPlan.arguments()).thenReturn(Collections.emptyMap()); + when(argumentPlan.operatorType()).thenReturn("Argument"); + + Plan childPlan = mock(Plan.class); + Map args = Collections.singletonMap("Details", new StringValue(stringOfLength(TablePlanFormatter.MAX_DETAILS_COLUMN_WIDTH + 5))); + when(childPlan.arguments()).thenReturn(args); + when(childPlan.operatorType()).thenReturn("Expand"); + doReturn(new ArrayList(){{add( argumentPlan);add( argumentPlan);}}).when(childPlan).children(); + Plan plan = mock(Plan.class); String details = stringOfLength(TablePlanFormatter.MAX_DETAILS_COLUMN_WIDTH + 1); - Map args = Collections.singletonMap("Details", new StringValue(details)); + args = Collections.singletonMap("Details", new StringValue(details)); when(plan.arguments()).thenReturn(args); when(plan.operatorType()).thenReturn("Projection"); + doReturn(new ArrayList(){{add( childPlan);add( childPlan);}}).when(plan).children(); - assertThat(tablePlanFormatter.formatPlan( plan ), containsString("| +Projection | " + details.substring( 0, TablePlanFormatter.MAX_DETAILS_COLUMN_WIDTH - 3 ) + "... |")); + assertThat(tablePlanFormatter.formatPlan( plan ), is(String.join(NEWLINE, + "+---------------+------------------------------------------------------------------------------------------------------+", + "| Operator | Details |", + "+---------------+------------------------------------------------------------------------------------------------------+", + "| +Projection | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |", + "| | | a |", + "| |\\ +------------------------------------------------------------------------------------------------------+", + "| | +Expand | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |", + "| | | | aaaaa |", + "| | |\\ +------------------------------------------------------------------------------------------------------+", + "| | | +Argument | |", + "| | | +------------------------------------------------------------------------------------------------------+", + "| | +Argument | |", + "| | +------------------------------------------------------------------------------------------------------+", + "| +Expand | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |", + "| | | aaaaa |", + "| |\\ +------------------------------------------------------------------------------------------------------+", + "| | +Argument | |", + "| | +------------------------------------------------------------------------------------------------------+", + "| +Argument | |", + "+---------------+------------------------------------------------------------------------------------------------------+", ""))); } private String stringOfLength(int length) {