diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/Cli.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/Cli.java index 190866789e89..c139966e9296 100644 --- a/ksql-cli/src/main/java/io/confluent/ksql/cli/Cli.java +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/Cli.java @@ -215,7 +215,9 @@ void handleLine(final String line) throws Exception { return; } - handleStatements(line); + if (!terminal.maybeHandleCliSpecificCommands(trimmedLine)) { + handleStatements(trimmedLine); + } } /** diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/CliConfig.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/CliConfig.java new file mode 100644 index 000000000000..a26c5312530e --- /dev/null +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/CliConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright 2019 Confluent Inc. + * + * Licensed under the Confluent Community License (the "License"; you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at + * + * http://www.confluent.io/confluent-community-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package io.confluent.ksql.cli.console; + +import io.confluent.ksql.configdef.ConfigValidators; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.common.config.AbstractConfig; +import org.apache.kafka.common.config.ConfigDef; +import org.apache.kafka.common.config.ConfigDef.Importance; +import org.apache.kafka.common.config.ConfigDef.Type; + +public class CliConfig extends AbstractConfig { + + public static final String WRAP_CONFIG = "WRAP"; + + private static final ConfigDef CONFIG_DEF = new ConfigDef() + .define( + WRAP_CONFIG, + Type.STRING, + OnOff.ON.name(), + ConfigValidators.enumValues(OnOff.class), + Importance.MEDIUM, + "A value of 'OFF' will clip lines to ensure that query results do not exceed the " + + "terminal width (i.e. each row will appear on a single line)." + ); + + public CliConfig(final Map originals) { + super(CONFIG_DEF, originals); + } + + public CliConfig with(final String property, final Object value) { + final Map originals = new HashMap<>(originals()); + originals.put(property, value); + return new CliConfig(originals); + } + + @SuppressWarnings("unused") // used in validation + public enum OnOff { + ON, OFF + } + +} diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/Console.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/Console.java index f5e250625e99..14ec12708a30 100644 --- a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/Console.java +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/Console.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import io.confluent.ksql.GenericRow; import io.confluent.ksql.cli.console.KsqlTerminal.HistoryEntry; @@ -185,7 +186,7 @@ private static Handler1 tablePrinter private final RowCaptor rowCaptor; private OutputFormat outputFormat; private Optional spoolFile = Optional.empty(); - + private CliConfig config; public interface RowCaptor { void addRow(GenericRow row); @@ -225,6 +226,7 @@ public Console( this.rowCaptor = Objects.requireNonNull(rowCaptor, "rowCaptor"); this.cliSpecificCommands = Maps.newLinkedHashMap(); this.objectMapper = JsonMapper.INSTANCE.mapper; + this.config = new CliConfig(ImmutableMap.of()); } public PrintWriter writer() { @@ -268,6 +270,10 @@ public void handle(final Signal signal, final SignalHandler signalHandler) { terminal.handle(signal, signalHandler); } + public void setCliProperty(final String name, final Object value) { + config = config.with(name, value); + } + @Override public void close() { terminal.close(); @@ -282,14 +288,7 @@ public Map getCliSpecificCommands() { } public String readLine() { - String line; - - do { - line = terminal.readLine(); - - } while (maybeHandleCliSpecificCommands(line)); - - return line; + return terminal.readLine(); } public List getHistory() { @@ -402,14 +401,11 @@ private Optional getCliCommand(final String line) { return Optional.empty(); } - final String reconstructed = parts.stream() - .collect(Collectors.joining(" ")); - - final String asLowerCase = reconstructed.toLowerCase(); + final String command = String.join(" ", parts); - return cliSpecificCommands.entrySet().stream() - .filter(e -> asLowerCase.startsWith(e.getKey())) - .map(e -> CliCmdExecutor.of(e.getValue(), parts)) + return cliSpecificCommands.values().stream() + .filter(cliSpecificCommand -> cliSpecificCommand.matches(command)) + .map(cliSpecificCommand -> CliCmdExecutor.of(cliSpecificCommand, parts)) .findFirst(); } @@ -418,7 +414,7 @@ private void printAsTable( final List fields ) { rowCaptor.addRow(row); - writer().println(TabularRow.createRow(getWidth(), fields, row)); + writer().println(TabularRow.createRow(getWidth(), fields, row, config)); flush(); } @@ -787,7 +783,7 @@ public void addRows(final List> fields) { } } - private boolean maybeHandleCliSpecificCommands(final String line) { + public boolean maybeHandleCliSpecificCommands(final String line) { if (line == null) { return false; } diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliCommandRegisterUtil.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliCommandRegisterUtil.java index b940484961d3..e6a798c157e4 100644 --- a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliCommandRegisterUtil.java +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliCommandRegisterUtil.java @@ -68,5 +68,9 @@ public static void registerDefaultCommands( console.registerCliSpecificCommand( Spool.create(console::setSpool, console::unsetSpool)); + + console.registerCliSpecificCommand( + SetCliProperty.create(console::setCliProperty) + ); } } diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliSpecificCommand.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliSpecificCommand.java index 402fa872d158..3f997fa8dcee 100644 --- a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliSpecificCommand.java +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/CliSpecificCommand.java @@ -20,6 +20,14 @@ public interface CliSpecificCommand { + /** + * @param command the full command + * @return whether or not {@code command} is an instance of {@code this} + */ + default boolean matches(final String command) { + return command.toLowerCase().startsWith(getName().toLowerCase()); + } + /** * Get the name of the command. * diff --git a/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/SetCliProperty.java b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/SetCliProperty.java new file mode 100644 index 000000000000..10291683df8f --- /dev/null +++ b/ksql-cli/src/main/java/io/confluent/ksql/cli/console/cmd/SetCliProperty.java @@ -0,0 +1,58 @@ +/* + * Copyright 2019 Confluent Inc. + * + * Licensed under the Confluent Community License (the "License"; you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at + * + * http://www.confluent.io/confluent-community-license + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package io.confluent.ksql.cli.console.cmd; + +import java.io.PrintWriter; +import java.util.List; +import java.util.function.BiConsumer; + +final class SetCliProperty implements CliSpecificCommand { + + private static final String HELP = "set :" + System.lineSeparator() + + "\tSets a CLI local property. NOTE that this differs from setting a KSQL " + + "property with 'SET property=value' in that it does not affect the server."; + + private final BiConsumer setProperty; + + public static SetCliProperty create(final BiConsumer setProperty) { + return new SetCliProperty(setProperty); + } + + private SetCliProperty(final BiConsumer setProperty) { + this.setProperty = setProperty; + } + + @Override + public boolean matches(final String command) { + return command.toLowerCase().startsWith(getName().toLowerCase()) && !command.contains("="); + } + + @Override + public String getName() { + return "set"; + } + + @Override + public String getHelpMessage() { + return HELP; + } + + @Override + public void execute(final List args, final PrintWriter terminal) { + CliCmdUtil.ensureArgCountBounds(args, 2, 2, getHelpMessage()); + setProperty.accept(args.get(0), args.get(1)); + } +} diff --git a/ksql-cli/src/main/java/io/confluent/ksql/util/TabularRow.java b/ksql-cli/src/main/java/io/confluent/ksql/util/TabularRow.java index 4eb23fb81d38..35196c9c2137 100644 --- a/ksql-cli/src/main/java/io/confluent/ksql/util/TabularRow.java +++ b/ksql-cli/src/main/java/io/confluent/ksql/util/TabularRow.java @@ -19,6 +19,8 @@ import com.google.common.base.Splitter; import com.google.common.base.Strings; import io.confluent.ksql.GenericRow; +import io.confluent.ksql.cli.console.CliConfig; +import io.confluent.ksql.cli.console.CliConfig.OnOff; import io.confluent.ksql.rest.entity.FieldInfo; import java.util.ArrayList; import java.util.List; @@ -27,29 +29,34 @@ public class TabularRow { + private static final String CLIPPED = "..."; private static final int MIN_CELL_WIDTH = 5; private final int width; private final List value; private final List header; private final boolean isHeader; + private final boolean shouldWrap; public static TabularRow createHeader(final int width, final List header) { return new TabularRow( width, header.stream().map(FieldInfo::getName).collect(Collectors.toList()), - null); + null, + true); } public static TabularRow createRow( final int width, final List header, - final GenericRow value + final GenericRow value, + final CliConfig config ) { return new TabularRow( width, header.stream().map(FieldInfo::getName).collect(Collectors.toList()), - value.getColumns().stream().map(Objects::toString).collect(Collectors.toList()) + value.getColumns().stream().map(Objects::toString).collect(Collectors.toList()), + config.getString(CliConfig.WRAP_CONFIG).equalsIgnoreCase(OnOff.ON.toString()) ); } @@ -57,12 +64,14 @@ public static TabularRow createRow( TabularRow( final int width, final List header, - final List value + final List value, + final boolean shouldWrap ) { this.header = Objects.requireNonNull(header, "header"); this.width = width; this.value = value; this.isHeader = value == null; + this.shouldWrap = shouldWrap; } @Override @@ -92,7 +101,7 @@ public String toString() { .map(s -> addUntil(s, createCell("", cellWidth), maxSplit)) .collect(Collectors.toList()); - formatRow(builder, buffered, maxSplit); + formatRow(builder, buffered, shouldWrap ? maxSplit : 1); if (isHeader) { builder.append('\n'); @@ -111,7 +120,15 @@ private static void formatRow( for (int row = 0; row < numRows; row++) { builder.append('|'); for (int col = 0; col < columns.size(); col++) { - builder.append(columns.get(col).get(row)); + final String colValue = columns.get(col).get(row); + if (shouldClip(columns.get(col), numRows)) { + builder.append(colValue, 0, colValue.length() - CLIPPED.length()) + .append(CLIPPED) + .append('|'); + } else { + builder.append(colValue) + .append('|'); + } } if (row != numRows - 1) { builder.append('\n'); @@ -119,6 +136,15 @@ private static void formatRow( } } + private static boolean shouldClip(final List parts, final int rowsToPrint) { + // clip if there are more than one line and any of the remaining lines are non-empty + return parts.size() > rowsToPrint + && parts.subList(rowsToPrint, parts.size()) + .stream() + .map(String::trim) + .noneMatch(String::isEmpty); + } + @SuppressWarnings("UnstableApiUsage") private static List splitToFixed(final String value, final int width) { return Splitter.fixedLength(width) @@ -141,7 +167,7 @@ private static void separatingLine( } private static String createCell(final String value, final int width) { - final String format = "%-" + width + "s|"; + final String format = "%-" + width + "s"; return String.format(format, value); } diff --git a/ksql-cli/src/test/java/io/confluent/ksql/cli/CliTest.java b/ksql-cli/src/test/java/io/confluent/ksql/cli/CliTest.java index f7e952760f6b..683bdb7a0f44 100644 --- a/ksql-cli/src/test/java/io/confluent/ksql/cli/CliTest.java +++ b/ksql-cli/src/test/java/io/confluent/ksql/cli/CliTest.java @@ -996,7 +996,7 @@ private void givenRequestPipelining(final String setting) { private void runCliSpecificCommand(final String command) { when(lineSupplier.get()).thenReturn(command).thenReturn(""); - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); } private void givenRunInteractivelyWillExit() { diff --git a/ksql-cli/src/test/java/io/confluent/ksql/cli/console/ConsoleTest.java b/ksql-cli/src/test/java/io/confluent/ksql/cli/console/ConsoleTest.java index dc9ddbd261cd..4143844a1e1f 100644 --- a/ksql-cli/src/test/java/io/confluent/ksql/cli/console/ConsoleTest.java +++ b/ksql-cli/src/test/java/io/confluent/ksql/cli/console/ConsoleTest.java @@ -135,6 +135,8 @@ public ConsoleTest(final OutputFormat outputFormat) { this.console = new Console(outputFormat, terminal, new NoOpRowCaptor()); when(cliCommand.getName()).thenReturn(CLI_CMD_NAME); + when(cliCommand.matches(any())) + .thenAnswer(i -> ((String) i.getArgument(0)).toLowerCase().startsWith(CLI_CMD_NAME.toLowerCase())); console.registerCliSpecificCommand(cliCommand); } @@ -1262,7 +1264,7 @@ public void shouldExecuteCliCommands() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of()), any()); @@ -1276,7 +1278,7 @@ public void shouldExecuteCliCommandWithArgsTrimmingWhiteSpace() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of("Arg0", "Arg1")), any()); @@ -1290,7 +1292,7 @@ public void shouldExecuteCliCommandWithQuotedArgsContainingSpaces() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of("Arg0", "Arg 1")), any()); @@ -1304,7 +1306,7 @@ public void shouldSupportOtherWhitespaceBetweenCliCommandAndArgs() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of("Arg0", "Arg 1")), any()); @@ -1318,7 +1320,7 @@ public void shouldSupportCmdBeingTerminatedWithSemiColon() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of("Arg0")), any()); @@ -1332,7 +1334,7 @@ public void shouldSupportCmdWithQuotedArgBeingTerminatedWithSemiColon() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand).execute(eq(ImmutableList.of("Arg0")), any()); @@ -1346,7 +1348,7 @@ public void shouldFailIfCommandNameIsQuoted() { .thenReturn("not a CLI command;"); // When: - console.readLine(); + console.maybeHandleCliSpecificCommands(console.readLine()); // Then: verify(cliCommand, never()).execute(any(), any()); @@ -1354,30 +1356,20 @@ public void shouldFailIfCommandNameIsQuoted() { @Test public void shouldSwallowCliCommandLines() { - // Given: - when(lineSupplier.get()) - .thenReturn(CLI_CMD_NAME) - .thenReturn("not a CLI command;"); - // When: - final String result = console.readLine(); + final boolean executed = console.maybeHandleCliSpecificCommands(CLI_CMD_NAME); // Then: - assertThat(result, is("not a CLI command;")); + assertThat("expected CLI command to be executed", executed); } @Test public void shouldSwallowCliCommandLinesEvenWithWhiteSpace() { - // Given: - when(lineSupplier.get()) - .thenReturn(" \t " + CLI_CMD_NAME + " \t ") - .thenReturn("not a CLI command;"); - // When: - final String result = console.readLine(); + final boolean executed = console.maybeHandleCliSpecificCommands("NOT CLI COMMAND"); // Then: - assertThat(result, is("not a CLI command;")); + assertThat("not a cli command", !executed); } private static List buildTestSchema(final SqlType... fieldTypes) { diff --git a/ksql-cli/src/test/java/io/confluent/ksql/util/TabularRowTest.java b/ksql-cli/src/test/java/io/confluent/ksql/util/TabularRowTest.java index b89462bad591..06990a8671f4 100644 --- a/ksql-cli/src/test/java/io/confluent/ksql/util/TabularRowTest.java +++ b/ksql-cli/src/test/java/io/confluent/ksql/util/TabularRowTest.java @@ -31,7 +31,7 @@ public void shouldFormatHeader() { final List header = ImmutableList.of("foo", "bar"); // When: - final String formatted = new TabularRow(20, header, null).toString(); + final String formatted = new TabularRow(20, header, null, true).toString(); // Then: assertThat(formatted, is("" @@ -46,7 +46,7 @@ public void shouldMultilineFormatHeader() { final List header = ImmutableList.of("foo", "bar is a long string"); // When: - final String formatted = new TabularRow(20, header, null).toString(); + final String formatted = new TabularRow(20, header, null, true).toString(); // Then: assertThat(formatted, is("" @@ -63,7 +63,7 @@ public void shouldFormatRow() { final List header = ImmutableList.of("foo", "bar"); // When: - final String formatted = new TabularRow(20, header, header).toString(); + final String formatted = new TabularRow(20, header, header, true).toString(); // Then: assertThat(formatted, is("|foo |bar |")); @@ -75,7 +75,7 @@ public void shouldMultilineFormatRow() { final List header = ImmutableList.of("foo", "bar is a long string"); // When: - final String formatted = new TabularRow(20, header, header).toString(); + final String formatted = new TabularRow(20, header, header, true).toString(); // Then: assertThat(formatted, is("" @@ -84,13 +84,26 @@ public void shouldMultilineFormatRow() { + "| |ring |")); } + @Test + public void shouldClipMultilineFormatRow() { + // Given: + final List header = ImmutableList.of("foo", "bar is a long string"); + + // When: + final String formatted = new TabularRow(20, header, header, false).toString(); + + // Then: + assertThat(formatted, is("" + + "|foo |bar i...|")); + } + @Test public void shouldFormatNoColumns() { // Given: final List header = ImmutableList.of(); // When: - final String formatted = new TabularRow(20, header, null).toString(); + final String formatted = new TabularRow(20, header, null, true).toString(); // Then: assertThat(formatted, isEmptyString()); @@ -102,7 +115,7 @@ public void shouldFormatMoreColumnsThanWidth() { final List header = ImmutableList.of("foo", "bar", "baz"); // When: - final String formatted = new TabularRow(3, header, null).toString(); + final String formatted = new TabularRow(3, header, null, true).toString(); // Then: assertThat(formatted,