This is an automated email from the ASF dual-hosted git repository.
apkhmv pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push:
new 9707fe8742b IGNITE-27516 Add paged result fetching for CLI SQL (#7499)
9707fe8742b is described below
commit 9707fe8742b301165e16159b15c7464259a63f1d
Author: Aleksandr Pakhomov <[email protected]>
AuthorDate: Wed Mar 4 13:28:25 2026 +0300
IGNITE-27516 Add paged result fetching for CLI SQL (#7499)
---
.../cli/commands/sql/ItSqlReplPagedResultTest.java | 159 ++++++++++++
.../ignite/internal/cli/call/sql/SqlQueryCall.java | 3 +-
.../commands/sql/PagedSqlExecutionPipeline.java | 283 +++++++++++++++++++++
.../cli/commands/sql/SqlExecReplCommand.java | 52 ++--
.../ignite/internal/cli/config/CliConfigKeys.java | 10 +-
.../cli/core/repl/terminal/PagerSupport.java | 123 ++++++++-
.../internal/cli/decorators/TruncationConfig.java | 48 ++--
.../ignite/internal/cli/sql/PagedSqlResult.java | 193 ++++++++++++++
.../apache/ignite/internal/cli/sql/SqlManager.java | 33 ++-
.../cli/sql/table/StreamingTableRenderer.java | 166 ++++++++++++
.../ignite/internal/cli/sql/table/Table.java | 67 +++--
.../ignite/internal/cli/util/TableTruncator.java | 4 +-
.../internal/cli/sql/PagedSqlResultTest.java | 247 ++++++++++++++++++
.../cli/sql/table/StreamingTableRendererTest.java | 223 ++++++++++++++++
.../ignite/internal/cli/sql/table/TableTest.java | 86 ++++++-
15 files changed, 1625 insertions(+), 72 deletions(-)
diff --git
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/sql/ItSqlReplPagedResultTest.java
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/sql/ItSqlReplPagedResultTest.java
new file mode 100644
index 00000000000..06d83ac38ff
--- /dev/null
+++
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/sql/ItSqlReplPagedResultTest.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.commands.sql;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import org.apache.ignite.internal.cli.CliIntegrationTest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for SQL REPL paged result fetching.
+ */
+class ItSqlReplPagedResultTest extends CliIntegrationTest {
+
+ private static final String TEST_TABLE = "paged_result_test";
+ private static final int TOTAL_ROWS = 50;
+
+ @BeforeEach
+ void createTestTable() {
+ sql("CREATE TABLE " + TEST_TABLE + " (id INT PRIMARY KEY, name
VARCHAR(100), val INT)");
+
+ StringBuilder insertSql = new StringBuilder("INSERT INTO
").append(TEST_TABLE).append(" VALUES ");
+ for (int i = 1; i <= TOTAL_ROWS; i++) {
+ if (i > 1) {
+ insertSql.append(", ");
+ }
+ insertSql.append("(").append(i).append(",
'Name_").append(i).append("', ").append(i * 10).append(")");
+ }
+ sql(insertSql.toString());
+ }
+
+ @AfterEach
+ void dropTestTable() {
+ sql("DROP TABLE IF EXISTS " + TEST_TABLE);
+ }
+
+ @Test
+ @DisplayName("Should handle paged results with small page size")
+ void smallPageSize() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"10");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE);
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("ID"),
+ () -> assertOutputContains("NAME"),
+ () -> assertOutputContains("VAL"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should work with default page size")
+ void defaultPageSize() {
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE);
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("ID"),
+ () -> assertOutputContains("NAME"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should fall back to default on invalid page size")
+ void invalidPageSize() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"-10");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE);
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("ID"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should handle single row result")
+ void singleRowResult() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"10");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE +
" WHERE id = 1");
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("Name_1"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should handle empty result set")
+ void emptyResult() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"10");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE +
" WHERE id > 1000");
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should handle UPDATE statements")
+ void updateStatement() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"10");
+ execute("sql", "--jdbc-url", JDBC_URL, "UPDATE " + TEST_TABLE + " SET
val = 999 WHERE id = 1");
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("Updated"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should work with page size of 1")
+ void pageSizeOne() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"1");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE +
" LIMIT 5");
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("ID"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should handle large page size")
+ void largePageSize() {
+ execute("cli", "config", "set", "ignite.cli.sql.display-page-size",
"10000");
+ execute("sql", "--jdbc-url", JDBC_URL, "SELECT * FROM " + TEST_TABLE);
+
+ assertAll(
+ this::assertExitCodeIsZero,
+ () -> assertOutputContains("ID"),
+ this::assertErrOutputIsEmpty
+ );
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/sql/SqlQueryCall.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/sql/SqlQueryCall.java
index 98fbb45c05f..1a51474eb64 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/sql/SqlQueryCall.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/sql/SqlQueryCall.java
@@ -52,7 +52,8 @@ public class SqlQueryCall implements Call<StringCallInput,
SqlQueryResult> {
}
}
- private static String trimQuotes(String input) {
+ /** Trims surrounding double quotes from the input string. */
+ public static String trimQuotes(String input) {
if (input.startsWith("\"") && input.endsWith("\"") && input.length() >
2) {
return input.substring(1, input.length() - 1);
}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/PagedSqlExecutionPipeline.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/PagedSqlExecutionPipeline.java
new file mode 100644
index 00000000000..fe8ae95373e
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/PagedSqlExecutionPipeline.java
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.commands.sql;
+
+import java.io.PrintWriter;
+import java.sql.SQLException;
+import java.util.function.Consumer;
+import org.apache.ignite.internal.cli.call.sql.SqlQueryCall;
+import org.apache.ignite.internal.cli.core.call.CallExecutionPipeline;
+import org.apache.ignite.internal.cli.core.call.StringCallInput;
+import org.apache.ignite.internal.cli.core.exception.ExceptionWriter;
+import
org.apache.ignite.internal.cli.core.exception.handler.SqlExceptionHandler;
+import
org.apache.ignite.internal.cli.core.repl.context.CommandLineContextProvider;
+import org.apache.ignite.internal.cli.core.repl.terminal.PagerSupport;
+import org.apache.ignite.internal.cli.decorators.TruncationConfig;
+import org.apache.ignite.internal.cli.logger.CliLoggers;
+import org.apache.ignite.internal.cli.sql.PagedSqlResult;
+import org.apache.ignite.internal.cli.sql.SqlManager;
+import org.apache.ignite.internal.cli.sql.table.StreamingTableRenderer;
+import org.apache.ignite.internal.cli.sql.table.Table;
+import org.apache.ignite.internal.cli.util.TableTruncator;
+import org.jline.terminal.Terminal;
+
+/**
+ * A pipeline for paged SQL execution that streams results continuously to the
terminal.
+ */
+class PagedSqlExecutionPipeline implements
CallExecutionPipeline<StringCallInput, Object> {
+ private final SqlManager sqlManager;
+ private final String sql;
+ private final int pageSize;
+ private final TruncationConfig truncationConfig;
+ private final PagerSupport pagerSupport;
+ private final Terminal terminal;
+ private final boolean plain;
+ private final boolean timed;
+ private final boolean[] verbose;
+ private final PrintWriter verboseErr;
+
+ PagedSqlExecutionPipeline(
+ SqlManager sqlManager,
+ String sql,
+ int pageSize,
+ TruncationConfig truncationConfig,
+ PagerSupport pagerSupport,
+ Terminal terminal,
+ boolean plain,
+ boolean timed,
+ boolean[] verbose,
+ PrintWriter verboseErr
+ ) {
+ this.sqlManager = sqlManager;
+ this.sql = sql;
+ this.pageSize = pageSize;
+ this.truncationConfig = truncationConfig;
+ this.pagerSupport = pagerSupport;
+ this.terminal = terminal;
+ this.plain = plain;
+ this.timed = timed;
+ this.verbose = verbose;
+ this.verboseErr = verboseErr;
+ }
+
+ @Override
+ public int runPipeline() {
+ try {
+ if (verbose.length > 0) {
+ CliLoggers.startOutputRedirect(verboseErr, verbose);
+ }
+ return runPipelineInternal();
+ } finally {
+ if (verbose.length > 0) {
+ CliLoggers.stopOutputRedirect();
+ }
+ }
+ }
+
+ private int runPipelineInternal() {
+ PrintWriter err = CommandLineContextProvider.getContext().err();
+ PrintWriter out = CommandLineContextProvider.getContext().out();
+
+ try (PagedSqlResult pagedResult =
sqlManager.executePaged(SqlQueryCall.trimQuotes(sql), pageSize)) {
+ if (!pagedResult.hasResultSet()) {
+ int updateCount = pagedResult.getUpdateCount();
+ out.println(updateCount >= 0 ? "Updated " + updateCount + "
rows." : "OK!");
+ if (timed) {
+ out.println("Query executed in " +
pagedResult.getDurationMs() + " ms");
+ }
+ out.flush();
+ return 0;
+ }
+
+ Table<String> firstPage = pagedResult.fetchNextPage();
+ if (firstPage == null) {
+ if (timed) {
+ out.println("Query executed in " +
pagedResult.getDurationMs() + " ms, 0 rows returned");
+ }
+ return 0;
+ }
+
+ int totalRows;
+ if (plain) {
+ totalRows = streamPlain(out, pagedResult, firstPage);
+ } else {
+ totalRows = streamBoxDrawing(out, pagedResult, firstPage);
+ }
+
+ CliLoggers.verboseLog(1, "<-- " + totalRows + " row(s) (" +
pagedResult.getDurationMs() + "ms)");
+
+ if (timed) {
+ out.println("Query executed in " + pagedResult.getDurationMs()
+ " ms, " + totalRows + " rows returned");
+ out.flush();
+ }
+
+ return 0;
+ } catch (SQLException e) {
+
SqlExceptionHandler.INSTANCE.handle(ExceptionWriter.fromPrintWriter(err), e);
+ return 1;
+ }
+ }
+
+ private int streamPlain(PrintWriter out, PagedSqlResult pagedResult,
Table<String> firstPage)
+ throws SQLException {
+ if (pagerSupport.isPagerEnabled() && isRealTerminal()) {
+ return streamPlainPager(pagedResult, firstPage);
+ } else {
+ return streamPlainDirect(out, pagedResult, firstPage);
+ }
+ }
+
+ private int streamPlainPager(PagedSqlResult pagedResult, Table<String>
firstPage) throws SQLException {
+ try (PagerSupport.StreamingPager pager = pagerSupport.openStreaming())
{
+ Consumer<String> sink = pager::write;
+ sink.accept(String.join("\t", firstPage.header()) +
System.lineSeparator());
+
+ int totalRows = printPlainRows(sink, firstPage.content());
+
+ Table<String> page;
+ while ((page = pagedResult.fetchNextPage()) != null) {
+ totalRows += printPlainRows(sink, page.content());
+ }
+
+ return totalRows;
+ }
+ }
+
+ private int streamPlainDirect(PrintWriter out, PagedSqlResult pagedResult,
Table<String> firstPage)
+ throws SQLException {
+ Consumer<String> sink = out::print;
+ sink.accept(String.join("\t", firstPage.header()) +
System.lineSeparator());
+
+ int totalRows = printPlainRows(sink, firstPage.content());
+ out.flush();
+
+ Table<String> page;
+ while ((page = pagedResult.fetchNextPage()) != null) {
+ totalRows += printPlainRows(sink, page.content());
+ out.flush();
+ }
+
+ return totalRows;
+ }
+
+ private int printPlainRows(Consumer<String> sink, Object[][] content) {
+ String lineSep = System.lineSeparator();
+ for (Object[] row : content) {
+ StringBuilder line = new StringBuilder();
+ for (int i = 0; i < row.length; i++) {
+ if (i > 0) {
+ line.append('\t');
+ }
+ line.append(row[i]);
+ }
+ line.append(lineSep);
+ sink.accept(line.toString());
+ }
+ return content.length;
+ }
+
+ private int streamBoxDrawing(PrintWriter out, PagedSqlResult pagedResult,
Table<String> firstPage)
+ throws SQLException {
+ return streamBoxDrawingBatch(out, pagedResult, firstPage);
+ }
+
+ private int streamBoxDrawingBatch(PrintWriter out, PagedSqlResult
pagedResult, Table<String> firstPage)
+ throws SQLException {
+ String[] columnNames = firstPage.header();
+
+ // Lock column widths from first page only so we can start streaming
immediately.
+ TruncationConfig widthCalcConfig = truncationConfig.isTruncateEnabled()
+ ? truncationConfig
+ : new TruncationConfig(true, Integer.MAX_VALUE, 0);
+ TableTruncator truncator = new TableTruncator(widthCalcConfig);
+ int[] lockedWidths = truncator.calculateColumnWidths(columnNames,
firstPage.content());
+
+ String[] truncatedHeader = truncateRowCells(columnNames, lockedWidths);
+ StreamingTableRenderer renderer = new
StreamingTableRenderer(truncatedHeader, lockedWidths);
+
+ if (pagerSupport.isPagerEnabled() && isRealTerminal()) {
+ return streamBoxDrawingBatchPager(renderer, lockedWidths,
pagedResult, firstPage);
+ } else {
+ return streamBoxDrawingBatchDirect(out, renderer, lockedWidths,
pagedResult, firstPage);
+ }
+ }
+
+ private int streamBoxDrawingBatchPager(StreamingTableRenderer renderer,
int[] lockedWidths,
+ PagedSqlResult pagedResult, Table<String> firstPage) throws
SQLException {
+ try (PagerSupport.StreamingPager pager = pagerSupport.openStreaming())
{
+ Consumer<String> sink = pager::write;
+ sink.accept(renderer.renderHeader());
+
+ int rowOffset = renderPageRows(sink, renderer,
firstPage.content(), lockedWidths, 0);
+
+ Table<String> page;
+ while ((page = pagedResult.fetchNextPage()) != null) {
+ rowOffset = renderPageRows(sink, renderer, page.content(),
lockedWidths, rowOffset);
+ }
+
+ sink.accept(renderer.renderFooter());
+
+ return rowOffset;
+ }
+ }
+
+ private int streamBoxDrawingBatchDirect(PrintWriter out,
StreamingTableRenderer renderer, int[] lockedWidths,
+ PagedSqlResult pagedResult, Table<String> firstPage) throws
SQLException {
+ Consumer<String> sink = out::print;
+ sink.accept(renderer.renderHeader());
+
+ int rowOffset = renderPageRows(sink, renderer, firstPage.content(),
lockedWidths, 0);
+ out.flush();
+
+ Table<String> page;
+ while ((page = pagedResult.fetchNextPage()) != null) {
+ rowOffset = renderPageRows(sink, renderer, page.content(),
lockedWidths, rowOffset);
+ out.flush();
+ }
+
+ sink.accept(renderer.renderFooter());
+ out.flush();
+
+ return rowOffset;
+ }
+
+ private boolean isRealTerminal() {
+ String type = terminal.getType();
+ return type != null && !Terminal.TYPE_DUMB.equals(type) &&
!Terminal.TYPE_DUMB_COLOR.equals(type);
+ }
+
+ private int renderPageRows(Consumer<String> sink, StreamingTableRenderer
renderer,
+ Object[][] content, int[] lockedWidths, int rowOffset) {
+ for (int r = 0; r < content.length; r++) {
+ Object[] truncatedRow = new Object[content[r].length];
+ for (int c = 0; c < content[r].length; c++) {
+ truncatedRow[c] = TableTruncator.truncateCell(content[r][c],
lockedWidths[c]);
+ }
+ sink.accept(renderer.renderRow(truncatedRow, rowOffset + r == 0));
+ }
+ return rowOffset + content.length;
+ }
+
+ private String[] truncateRowCells(String[] row, int[] columnWidths) {
+ String[] result = new String[row.length];
+ for (int i = 0; i < row.length; i++) {
+ result[i] = TableTruncator.truncateCell(row[i], columnWidths[i]);
+ }
+ return result;
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/SqlExecReplCommand.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/SqlExecReplCommand.java
index 8f5e76a1ce5..986ea9465f3 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/SqlExecReplCommand.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/sql/SqlExecReplCommand.java
@@ -31,6 +31,7 @@ import static
org.apache.ignite.internal.cli.commands.Options.Constants.SCRIPT_F
import static
org.apache.ignite.internal.cli.commands.Options.Constants.TIMED_OPTION;
import static
org.apache.ignite.internal.cli.commands.Options.Constants.TIMED_OPTION_DESC;
import static
org.apache.ignite.internal.cli.commands.treesitter.parser.Parser.isTreeSitterParserAvailable;
+import static
org.apache.ignite.internal.cli.config.CliConfigKeys.Constants.DEFAULT_SQL_DISPLAY_PAGE_SIZE;
import static org.apache.ignite.internal.cli.core.style.AnsiStringSupport.ansi;
import static org.apache.ignite.internal.cli.core.style.AnsiStringSupport.fg;
@@ -41,7 +42,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.sql.SQLException;
import java.util.regex.Pattern;
-import org.apache.ignite.internal.cli.call.sql.SqlQueryCall;
import org.apache.ignite.internal.cli.commands.BaseCommand;
import
org.apache.ignite.internal.cli.commands.sql.help.IgniteSqlCommandCompleter;
import
org.apache.ignite.internal.cli.commands.treesitter.highlighter.SqlAttributedStringHighlighter;
@@ -60,12 +60,14 @@ import org.apache.ignite.internal.cli.core.repl.Session;
import
org.apache.ignite.internal.cli.core.repl.context.CommandLineContextProvider;
import
org.apache.ignite.internal.cli.core.repl.executor.RegistryCommandExecutor;
import org.apache.ignite.internal.cli.core.repl.executor.ReplExecutorProvider;
+import org.apache.ignite.internal.cli.core.repl.terminal.PagerSupport;
import org.apache.ignite.internal.cli.core.rest.ApiClientFactory;
import org.apache.ignite.internal.cli.core.style.AnsiStringSupport.Color;
-import org.apache.ignite.internal.cli.decorators.SqlQueryResultDecorator;
import org.apache.ignite.internal.cli.decorators.TruncationConfig;
+import org.apache.ignite.internal.cli.logger.CliLoggers;
import org.apache.ignite.internal.cli.sql.SqlManager;
import org.apache.ignite.internal.cli.sql.SqlSchemaProvider;
+import org.apache.ignite.internal.logger.IgniteLogger;
import org.apache.ignite.internal.util.StringUtils;
import org.apache.ignite.rest.client.api.ClusterManagementApi;
import org.apache.ignite.rest.client.invoker.ApiException;
@@ -90,6 +92,8 @@ import picocli.CommandLine.Parameters;
*/
@Command(name = "sql", description = "Executes SQL query")
public class SqlExecReplCommand extends BaseCommand implements Runnable {
+ private static final IgniteLogger LOG =
CliLoggers.forClass(SqlExecReplCommand.class);
+
@Option(names = JDBC_URL_OPTION, required = true, descriptionKey =
JDBC_URL_KEY, description = JDBC_URL_OPTION_DESC)
private String jdbc;
@@ -162,7 +166,7 @@ public class SqlExecReplCommand extends BaseCommand
implements Runnable {
.build());
} else {
String executeCommand = execOptions.file != null ?
extract(execOptions.file) : execOptions.command;
- createSqlExecPipeline(sqlManager,
executeCommand).runPipeline();
+ createPagedSqlExecPipeline(sqlManager,
executeCommand).runPipeline();
}
} catch (SQLException e) {
String url = session.info() == null ? null :
session.info().nodeUrl();
@@ -227,10 +231,10 @@ public class SqlExecReplCommand extends BaseCommand
implements Runnable {
private CallExecutionPipelineProvider provider(SqlManager sqlManager) {
return (executor, exceptionHandlers, line) ->
executor.hasCommand(dropSemicolon(line))
? createInternalCommandPipeline(executor, exceptionHandlers,
line)
- : createSqlExecPipeline(sqlManager, line);
+ : createPagedSqlExecPipeline(sqlManager, line);
}
- private CallExecutionPipeline<?, ?> createSqlExecPipeline(SqlManager
sqlManager, String line) {
+ private CallExecutionPipeline<?, ?> createPagedSqlExecPipeline(SqlManager
sqlManager, String line) {
TruncationConfig truncationConfig = TruncationConfig.fromConfig(
configManagerProvider,
terminal::getWidth,
@@ -239,17 +243,33 @@ public class SqlExecReplCommand extends BaseCommand
implements Runnable {
plain
);
- // Use CommandLineContextProvider to get the current REPL's output
writer,
- // not the outer command's writer. This ensures SQL output goes through
- // the nested REPL's output capture for proper pager support.
- return CallExecutionPipeline.builder(new SqlQueryCall(sqlManager))
- .inputProvider(() -> new StringCallInput(line))
- .output(CommandLineContextProvider.getContext().out())
- .errOutput(CommandLineContextProvider.getContext().err())
- .decorator(new SqlQueryResultDecorator(plain, timed,
truncationConfig))
- .verbose(verbose)
- .exceptionHandler(SqlExceptionHandler.INSTANCE)
- .build();
+ int pageSize = getPageSize();
+
+ PagerSupport pagerSupport = new PagerSupport(terminal,
configManagerProvider);
+
+ return new PagedSqlExecutionPipeline(
+ sqlManager, line, pageSize, truncationConfig, pagerSupport,
+ terminal, plain, timed, verbose, spec.commandLine().getErr()
+ );
+ }
+
+ private int getPageSize() {
+ String configValue =
configManagerProvider.get().getCurrentProperty(CliConfigKeys.SQL_DISPLAY_PAGE_SIZE.value());
+ if (configValue != null && !configValue.isEmpty()) {
+ try {
+ int pageSize = Integer.parseInt(configValue);
+ if (pageSize <= 0) {
+ LOG.warn("SQL display page size must be positive, got: {},
using default: {}",
+ pageSize, DEFAULT_SQL_DISPLAY_PAGE_SIZE);
+ return DEFAULT_SQL_DISPLAY_PAGE_SIZE;
+ }
+ return pageSize;
+ } catch (NumberFormatException e) {
+ LOG.warn("Invalid SQL display page size in config '{}', using
default: {}",
+ configValue, DEFAULT_SQL_DISPLAY_PAGE_SIZE);
+ }
+ }
+ return DEFAULT_SQL_DISPLAY_PAGE_SIZE;
}
private CallExecutionPipeline<?, ?>
createInternalCommandPipeline(RegistryCommandExecutor call,
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/config/CliConfigKeys.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/config/CliConfigKeys.java
index eb833611910..1eac18baaac 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/config/CliConfigKeys.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/config/CliConfigKeys.java
@@ -92,7 +92,10 @@ public enum CliConfigKeys {
OUTPUT_MAX_COLUMN_WIDTH(Constants.OUTPUT_MAX_COLUMN_WIDTH),
/** Color scheme property name (dark, light). */
- COLOR_SCHEME(Constants.COLOR_SCHEME);
+ COLOR_SCHEME(Constants.COLOR_SCHEME),
+
+ /** SQL display page size property name. */
+ SQL_DISPLAY_PAGE_SIZE(Constants.SQL_DISPLAY_PAGE_SIZE);
private final String value;
@@ -172,6 +175,11 @@ public enum CliConfigKeys {
public static final String OUTPUT_MAX_COLUMN_WIDTH =
"ignite.cli.output.max-column-width";
public static final String COLOR_SCHEME = "ignite.cli.color-scheme";
+
+ public static final String SQL_DISPLAY_PAGE_SIZE =
"ignite.cli.sql.display-page-size";
+
+ /** Default SQL display page size. */
+ public static final int DEFAULT_SQL_DISPLAY_PAGE_SIZE = 1000;
}
CliConfigKeys(String value) {
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/core/repl/terminal/PagerSupport.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/core/repl/terminal/PagerSupport.java
index f3b3f994f74..7fe59ba162f 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/core/repl/terminal/PagerSupport.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/core/repl/terminal/PagerSupport.java
@@ -17,6 +17,7 @@
package org.apache.ignite.internal.cli.core.repl.terminal;
+import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
@@ -41,8 +42,9 @@ public class PagerSupport {
/** Number of lines to reserve for prompt and status. */
private static final int TERMINAL_MARGIN = 2;
- private final boolean pagerEnabled;
- private final String pagerCommand;
+ private final ConfigManagerProvider configManagerProvider;
+ private final Boolean pagerEnabledOverride;
+ private final String pagerCommandOverride;
private final Terminal terminal;
/**
@@ -53,8 +55,9 @@ public class PagerSupport {
*/
public PagerSupport(Terminal terminal, ConfigManagerProvider
configManagerProvider) {
this.terminal = terminal;
- this.pagerEnabled = readPagerEnabled(configManagerProvider);
- this.pagerCommand = readPagerCommand(configManagerProvider);
+ this.configManagerProvider = configManagerProvider;
+ this.pagerEnabledOverride = null;
+ this.pagerCommandOverride = null;
}
/**
@@ -66,8 +69,99 @@ public class PagerSupport {
*/
PagerSupport(Terminal terminal, boolean pagerEnabled, String pagerCommand)
{
this.terminal = terminal;
- this.pagerEnabled = pagerEnabled;
- this.pagerCommand = resolveCommand(pagerCommand);
+ this.configManagerProvider = null;
+ this.pagerEnabledOverride = pagerEnabled;
+ this.pagerCommandOverride = resolveCommand(pagerCommand);
+ }
+
+ /**
+ * Opens a streaming pager that allows incremental writes to the pager
subprocess.
+ *
+ * <p>The caller must close the returned {@link StreamingPager} when done.
+ * If the pager process cannot be started, the returned pager falls back
to writing directly to the terminal.
+ *
+ * @return a new streaming pager instance
+ */
+ public StreamingPager openStreaming() {
+ String command = getPagerCommand();
+ try {
+ if (terminal != null) {
+ terminal.pause();
+ }
+ ProcessBuilder pb = createPagerProcess(command);
+ Process process = pb.start();
+ return new StreamingPager(process);
+ } catch (IOException e) {
+ // Resume terminal immediately since we failed to start the pager
+ if (terminal != null) {
+ try {
+ terminal.resume();
+ } catch (Exception ignored) {
+ // Ignore resume errors
+ }
+ }
+ return new StreamingPager(null);
+ }
+ }
+
+ /**
+ * A streaming pager that allows incremental writes to a pager subprocess.
+ *
+ * <p>When the pager process is available, chunks are written directly to
its stdin.
+ * When the process is unavailable (fallback mode), chunks are written to
the terminal.
+ */
+ public class StreamingPager implements Closeable {
+ private final Process process;
+ private final OutputStream outputStream;
+
+ StreamingPager(Process process) {
+ this.process = process;
+ this.outputStream = process != null ? process.getOutputStream() :
null;
+ }
+
+ /**
+ * Writes a chunk of text to the pager.
+ *
+ * @param chunk the text to write
+ */
+ public void write(String chunk) {
+ if (outputStream != null) {
+ try {
+ outputStream.write(chunk.getBytes(StandardCharsets.UTF_8));
+ outputStream.flush();
+ } catch (IOException e) {
+ // Pager process died (e.g. user pressed 'q'), ignore
+ }
+ } else if (terminal != null) {
+ terminal.writer().print(chunk);
+ terminal.writer().flush();
+ }
+ }
+
+ @Override
+ public void close() {
+ if (outputStream != null) {
+ try {
+ outputStream.close();
+ } catch (IOException ignored) {
+ // Ignore close errors
+ }
+ }
+ if (process != null) {
+ try {
+ process.waitFor();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ if (terminal != null) {
+ try {
+ terminal.resume();
+ } catch (Exception ignored) {
+ // Ignore resume errors
+ }
+ }
+ }
}
/**
@@ -94,7 +188,7 @@ public class PagerSupport {
* @return true if the output exceeds terminal height and pager is enabled
*/
boolean shouldUsePager(String output) {
- if (!pagerEnabled || terminal == null) {
+ if (!isPagerEnabled() || terminal == null) {
return false;
}
int lineCount = countLines(output);
@@ -146,21 +240,28 @@ public class PagerSupport {
}
/**
- * Returns the pager command to use.
+ * Returns the pager command to use. Reads dynamically from config on each
call.
*
* @return the pager command
*/
public String getPagerCommand() {
- return pagerCommand;
+ if (pagerCommandOverride != null) {
+ return pagerCommandOverride;
+ }
+ return readPagerCommand(configManagerProvider);
}
/**
- * Returns whether the pager is enabled.
+ * Returns whether the pager is enabled. Reads dynamically from config on
each call
+ * so that runtime config changes via {@code cli config set} take effect
immediately.
*
* @return true if pager is enabled
*/
public boolean isPagerEnabled() {
- return pagerEnabled;
+ if (pagerEnabledOverride != null) {
+ return pagerEnabledOverride;
+ }
+ return readPagerEnabled(configManagerProvider);
}
/**
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/TruncationConfig.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/TruncationConfig.java
index 518a4b32fa9..fdc7211bc1f 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/TruncationConfig.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/TruncationConfig.java
@@ -105,28 +105,11 @@ public class TruncationConfig {
? maxColWidthOverride
: readMaxColumnWidth(configManagerProvider);
- // Wrap the terminal width supplier with fallback logic
- IntSupplier widthWithFallback = () -> {
- int width = terminalWidthSupplier.getAsInt();
- if (width > 0) {
- return width;
- }
- // Try COLUMNS environment variable as fallback
- String columnsEnv = System.getenv("COLUMNS");
- if (columnsEnv != null && !columnsEnv.isEmpty()) {
- try {
- int envWidth = Integer.parseInt(columnsEnv);
- if (envWidth > 0) {
- return envWidth;
- }
- } catch (NumberFormatException ignored) {
- // Fall through to default
- }
- }
- return DEFAULT_TERMINAL_WIDTH;
- };
+ // Snapshot the terminal width eagerly to avoid inconsistent values
+ // between calls (Windows terminals can return 0 intermittently).
+ int terminalWidth = resolveTerminalWidth(terminalWidthSupplier);
- return new TruncationConfig(true, maxColumnWidth, widthWithFallback);
+ return new TruncationConfig(true, maxColumnWidth, terminalWidth);
}
/**
@@ -148,8 +131,7 @@ public class TruncationConfig {
}
/**
- * Returns the terminal width. This value is evaluated dynamically
- * to support terminal resize during a session.
+ * Returns the terminal width.
*
* @return terminal width (0 means no constraint)
*/
@@ -157,6 +139,26 @@ public class TruncationConfig {
return terminalWidthSupplier.getAsInt();
}
+ private static int resolveTerminalWidth(IntSupplier terminalWidthSupplier)
{
+ int width = terminalWidthSupplier.getAsInt();
+ if (width > 0) {
+ return width;
+ }
+ // Try COLUMNS environment variable as fallback
+ String columnsEnv = System.getenv("COLUMNS");
+ if (columnsEnv != null && !columnsEnv.isEmpty()) {
+ try {
+ int envWidth = Integer.parseInt(columnsEnv);
+ if (envWidth > 0) {
+ return envWidth;
+ }
+ } catch (NumberFormatException ignored) {
+ // Fall through to default
+ }
+ }
+ return DEFAULT_TERMINAL_WIDTH;
+ }
+
private static boolean readTruncateEnabled(ConfigManagerProvider
configManagerProvider) {
String value = configManagerProvider.get()
.getCurrentProperty(CliConfigKeys.OUTPUT_TRUNCATE.value());
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/PagedSqlResult.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/PagedSqlResult.java
new file mode 100644
index 00000000000..a9b378768cb
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/PagedSqlResult.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.sql;
+
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.ignite.internal.cli.sql.table.Table;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A paged SQL result that allows fetching rows in batches.
+ * This class manages the lifecycle of the underlying Statement and ResultSet,
+ * enabling lazy loading of result pages.
+ */
+public class PagedSqlResult implements AutoCloseable {
+ private final Statement statement;
+ private final int pageSize;
+ private final long startTime;
+
+ private @Nullable ResultSet currentResultSet;
+ private @Nullable List<String> columnNames;
+ private int columnCount;
+ private boolean exhausted;
+ private int totalRowsFetched;
+ /** Tracks if the cursor is already positioned on a row that hasn't been
read yet. */
+ private boolean hasPendingRow;
+
+ /**
+ * Constructor.
+ *
+ * @param statement The executed statement.
+ * @param pageSize The page size for fetching rows.
+ * @param startTime The query start time for duration calculation.
+ */
+ PagedSqlResult(Statement statement, int pageSize, long startTime) throws
SQLException {
+ this.statement = statement;
+ this.pageSize = pageSize;
+ this.startTime = startTime;
+ this.currentResultSet = statement.getResultSet();
+ this.exhausted = false;
+ this.totalRowsFetched = 0;
+
+ if (currentResultSet != null) {
+ initColumnMetadata();
+ }
+ }
+
+ private void initColumnMetadata() throws SQLException {
+ ResultSetMetaData metaData = currentResultSet.getMetaData();
+ columnCount = metaData.getColumnCount();
+ columnNames = new ArrayList<>();
+ for (int i = 1; i <= columnCount; i++) {
+ columnNames.add(metaData.getColumnLabel(i));
+ }
+ }
+
+ /**
+ * Checks if this result has a result set (SELECT query) or is an update
result.
+ *
+ * @return true if this is a SELECT result with rows.
+ */
+ public boolean hasResultSet() {
+ return currentResultSet != null;
+ }
+
+ /**
+ * Gets the update count for non-SELECT queries.
+ *
+ * @return The update count, or -1 if not applicable.
+ */
+ public int getUpdateCount() throws SQLException {
+ return statement.getUpdateCount();
+ }
+
+ /**
+ * Fetches the next page of rows.
+ *
+ * @return A Table containing the next page of rows, or null if no more
rows are available.
+ */
+ public @Nullable Table<String> fetchNextPage() throws SQLException {
+ if (exhausted || currentResultSet == null) {
+ return null;
+ }
+
+ List<String> content = new ArrayList<>();
+ int rowsRead = 0;
+ boolean hasMoreRows = false;
+
+ while (true) {
+ // Check if we have a row from a previous peek, otherwise advance
the cursor
+ boolean hasRow;
+ if (hasPendingRow) {
+ hasRow = true;
+ hasPendingRow = false;
+ } else {
+ hasRow = currentResultSet.next();
+ }
+
+ if (!hasRow) {
+ break;
+ }
+
+ if (rowsRead >= pageSize) {
+ // We have a row but already read enough - mark it as pending
for next call
+ hasMoreRows = true;
+ hasPendingRow = true;
+ break;
+ }
+
+ for (int i = 1; i <= columnCount; i++) {
+ content.add(currentResultSet.getString(i));
+ }
+ rowsRead++;
+ totalRowsFetched++;
+ }
+
+ if (rowsRead == 0) {
+ // No more rows in this result set, check for more result sets
+ if (statement.getMoreResults()) {
+ currentResultSet = statement.getResultSet();
+ if (currentResultSet != null) {
+ initColumnMetadata();
+ hasPendingRow = false;
+ return fetchNextPage();
+ }
+ }
+ exhausted = true;
+ return null;
+ }
+
+ return new Table<>(columnNames, content, hasMoreRows);
+ }
+
+ /**
+ * Checks if there are potentially more rows to fetch.
+ *
+ * @return true if there might be more rows available.
+ */
+ public boolean hasMoreRows() {
+ return !exhausted;
+ }
+
+ /**
+ * Gets the total number of rows fetched so far.
+ *
+ * @return The total row count.
+ */
+ public int getTotalRowsFetched() {
+ return totalRowsFetched;
+ }
+
+ /**
+ * Gets the query duration in milliseconds.
+ *
+ * @return Duration since query start.
+ */
+ public long getDurationMs() {
+ return System.currentTimeMillis() - startTime;
+ }
+
+ /**
+ * Gets the column names for the current result set.
+ *
+ * @return List of column names.
+ */
+ public @Nullable List<String> getColumnNames() {
+ return columnNames;
+ }
+
+ @Override
+ public void close() throws SQLException {
+ statement.close();
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/SqlManager.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/SqlManager.java
index 2ba05801d03..dab802d99e9 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/SqlManager.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/SqlManager.java
@@ -70,7 +70,7 @@ public class SqlManager implements AutoCloseable {
if (rs != null) {
logColumnMetadata(rs.getMetaData());
Table<String> table = Table.fromResultSet(rs);
- totalRows += table.content().length;
+ totalRows += table.getRowCount();
sqlQueryResultBuilder.addTable(table);
} else {
int updateCount = statement.getUpdateCount();
@@ -85,6 +85,37 @@ public class SqlManager implements AutoCloseable {
}
}
+ /**
+ * Execute provided SQL and return a paged result for lazy fetching.
+ *
+ * @param sql incoming string representation of SQL command.
+ * @param pageSize the page size for fetching rows.
+ * @return a PagedSqlResult for lazy row fetching.
+ * @throws SQLException in any case when SQL command can't be executed.
+ */
+ public PagedSqlResult executePaged(String sql, int pageSize) throws
SQLException {
+ logConnectionInfo();
+ CliLoggers.verboseLog(1, "--> SQL " + sql);
+
+ long startTime = System.currentTimeMillis();
+ Statement statement = connection.createStatement();
+ try {
+ // Set fetch size to avoid fetching all rows at once
+ statement.setFetchSize(pageSize);
+ statement.execute(sql);
+
+ ResultSet rs = statement.getResultSet();
+ if (rs != null) {
+ logColumnMetadata(rs.getMetaData());
+ }
+
+ return new PagedSqlResult(statement, pageSize, startTime);
+ } catch (SQLException e) {
+ statement.close();
+ throw e;
+ }
+ }
+
@Override
public void close() throws SQLException {
connection.close();
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRenderer.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRenderer.java
new file mode 100644
index 00000000000..72ccd29ec67
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRenderer.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.sql.table;
+
+/**
+ * Streaming table renderer that produces FlipTable-compatible Unicode
box-drawing output in phases.
+ *
+ * <p>Column widths are locked at construction time. Cells wider than the
locked width are truncated.
+ *
+ * <p>Output format:
+ * <pre>
+ * ╔════╤═══════╗ ← renderHeader()
+ * ║ ID │ NAME ║
+ * ╠════╪═══════╣
+ * ║ 1 │ Alice ║ ← renderRow()
+ * ╟────┼───────╢
+ * ║ 2 │ Bob ║ ← renderRow()
+ * ╚════╧═══════╝ ← renderFooter()
+ * </pre>
+ */
+public class StreamingTableRenderer {
+ // Box-drawing characters matching FlipTable output.
+ private static final char TOP_LEFT = '\u2554'; // ╔
+ private static final char TOP_RIGHT = '\u2557'; // ╗
+ private static final char BOTTOM_LEFT = '\u255A'; // ╚
+ private static final char BOTTOM_RIGHT = '\u255D'; // ╝
+ private static final char HORIZONTAL_DBL = '\u2550'; // ═
+ private static final char VERTICAL_DBL = '\u2551'; // ║
+ private static final char TOP_TEE = '\u2564'; // ╤
+ private static final char BOTTOM_TEE = '\u2567'; // ╧
+ private static final char HDR_LEFT = '\u2560'; // ╠
+ private static final char HDR_RIGHT = '\u2563'; // ╣
+ private static final char HDR_CROSS = '\u256A'; // ╪
+ private static final char ROW_LEFT = '\u255F'; // ╟
+ private static final char ROW_RIGHT = '\u2562'; // ╢
+ private static final char HORIZONTAL = '\u2500'; // ─
+ private static final char VERTICAL = '\u2502'; // │
+ private static final char ROW_CROSS = '\u253C'; // ┼
+
+ private final String[] columnNames;
+ private final int[] columnWidths;
+
+ /**
+ * Creates a new streaming table renderer.
+ *
+ * @param columnNames column header names (already truncated to fit widths)
+ * @param columnWidths locked column widths
+ */
+ public StreamingTableRenderer(String[] columnNames, int[] columnWidths) {
+ this.columnNames = columnNames;
+ this.columnWidths = columnWidths;
+ }
+
+ /**
+ * Renders the table header: top border, header row, and header separator.
+ *
+ * @return header string
+ */
+ public String renderHeader() {
+ StringBuilder sb = new StringBuilder();
+ // Top border: ╔════╤═══════╗
+ appendBorderLine(sb, TOP_LEFT, HORIZONTAL_DBL, TOP_TEE, TOP_RIGHT);
+ sb.append('\n');
+ // Header row: ║ ID │ NAME ║
+ appendDataRow(sb, columnNames);
+ sb.append('\n');
+ // Header separator: ╠════╪═══════╣
+ appendBorderLine(sb, HDR_LEFT, HORIZONTAL_DBL, HDR_CROSS, HDR_RIGHT);
+ sb.append('\n');
+ return sb.toString();
+ }
+
+ /**
+ * Renders a single data row, with a row separator before it if it's not
the first row.
+ *
+ * @param row cell values (already truncated to fit widths)
+ * @param isFirst whether this is the first data row (no separator before
it)
+ * @return row string
+ */
+ public String renderRow(Object[] row, boolean isFirst) {
+ StringBuilder sb = new StringBuilder();
+ if (!isFirst) {
+ // Row separator: ╟────┼───────╢
+ appendBorderLine(sb, ROW_LEFT, HORIZONTAL, ROW_CROSS, ROW_RIGHT);
+ sb.append('\n');
+ }
+ // Data row: ║ v │ v ║
+ appendDataRow(sb, row);
+ sb.append('\n');
+ return sb.toString();
+ }
+
+ /**
+ * Renders the table footer (bottom border).
+ *
+ * @return footer string
+ */
+ public String renderFooter() {
+ StringBuilder sb = new StringBuilder();
+ // Bottom border: ╚════╧═══════╝
+ appendBorderLine(sb, BOTTOM_LEFT, HORIZONTAL_DBL, BOTTOM_TEE,
BOTTOM_RIGHT);
+ sb.append('\n');
+ return sb.toString();
+ }
+
+ private void appendBorderLine(StringBuilder sb, char left, char fill, char
cross, char right) {
+ sb.append(left);
+ for (int i = 0; i < columnWidths.length; i++) {
+ if (i > 0) {
+ sb.append(cross);
+ }
+ // Each column cell has: space + content + space = width + 2
+ int cellWidth = columnWidths[i] + 2;
+ for (int j = 0; j < cellWidth; j++) {
+ sb.append(fill);
+ }
+ }
+ sb.append(right);
+ }
+
+ private void appendDataRow(StringBuilder sb, Object[] row) {
+ sb.append(VERTICAL_DBL);
+ for (int i = 0; i < columnWidths.length; i++) {
+ if (i > 0) {
+ sb.append(VERTICAL);
+ }
+ String value = cellToString(row, i);
+ sb.append(' ').append(pad(value, columnWidths[i])).append(' ');
+ }
+ sb.append(VERTICAL_DBL);
+ }
+
+ private static String cellToString(Object[] row, int index) {
+ if (index >= row.length || row[index] == null) {
+ return "";
+ }
+ return String.valueOf(row[index]);
+ }
+
+ private static String pad(String value, int width) {
+ if (value.length() >= width) {
+ return value.substring(0, width);
+ }
+ StringBuilder sb = new StringBuilder(width);
+ sb.append(value);
+ for (int i = value.length(); i < width; i++) {
+ sb.append(' ');
+ }
+ return sb.toString();
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/Table.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/Table.java
index 70098b0595e..b6ea7f53ae1 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/Table.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/sql/table/Table.java
@@ -35,6 +35,12 @@ public class Table<T> {
private final List<TableRow<T>> content;
+ /** Whether there are more rows available beyond those included in this
table. */
+ private final boolean hasMoreRows;
+
+ /** Number of rows in this table. */
+ private final int rowCount;
+
/**
* Constructor.
*
@@ -42,14 +48,27 @@ public class Table<T> {
* @param content list of row content. Size should be equals n * ids.size.
*/
public Table(List<String> ids, List<T> content) {
+ this(ids, content, false);
+ }
+
+ /**
+ * Constructor with truncation metadata.
+ *
+ * @param ids list of column names.
+ * @param content list of row content. Size should be equals n * ids.size.
+ * @param hasMoreRows whether there are more rows available beyond those
included.
+ */
+ public Table(List<String> ids, List<T> content, boolean hasMoreRows) {
if (!content.isEmpty() && !ids.isEmpty() && content.size() %
ids.size() != 0) {
throw new IllegalArgumentException("Content size should be
divisible by columns count");
}
this.header = parseHeader(ids);
this.content = new ArrayList<>();
+ this.hasMoreRows = hasMoreRows;
int columnsCount = ids.size();
int n = columnsCount != 0 ? content.size() / columnsCount : 0;
+ this.rowCount = n;
for (int i = 0; i < n; i++) {
List<T> elements = content.subList(i * columnsCount, (i + 1) *
columnsCount);
this.content.add(new TableRow<>(elements));
@@ -87,29 +106,45 @@ public class Table<T> {
return collect.toArray(new Object[0][0]);
}
+ /**
+ * Returns whether there are more rows available beyond those included in
this table.
+ *
+ * @return true if the result was truncated due to a row limit.
+ */
+ public boolean hasMoreRows() {
+ return hasMoreRows;
+ }
+
+ /**
+ * Returns the number of rows in this table.
+ *
+ * @return row count.
+ */
+ public int getRowCount() {
+ return rowCount;
+ }
+
/**
* Create method.
*
* @param resultSet coming result set.
- * @return istance of {@link Table}.
+ * @return instance of {@link Table}.
+ * @throws SQLException if a database access error occurs.
*/
- public static Table<String> fromResultSet(ResultSet resultSet) {
- try {
- ResultSetMetaData metaData = resultSet.getMetaData();
- int columnCount = metaData.getColumnCount();
- List<String> ids = new ArrayList<>();
+ public static Table<String> fromResultSet(ResultSet resultSet) throws
SQLException {
+ ResultSetMetaData metaData = resultSet.getMetaData();
+ int columnCount = metaData.getColumnCount();
+ List<String> ids = new ArrayList<>();
+ for (int i = 1; i <= columnCount; i++) {
+ ids.add(metaData.getColumnLabel(i));
+ }
+ List<String> content = new ArrayList<>();
+
+ while (resultSet.next()) {
for (int i = 1; i <= columnCount; i++) {
- ids.add(metaData.getColumnLabel(i));
- }
- List<String> content = new ArrayList<>();
- while (resultSet.next()) {
- for (int i = 1; i <= columnCount; i++) {
- content.add(resultSet.getString(i));
- }
+ content.add(resultSet.getString(i));
}
- return new Table<>(ids, content);
- } catch (SQLException e) {
- return null;
}
+ return new Table<>(ids, content);
}
}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/util/TableTruncator.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/util/TableTruncator.java
index b119b0ca597..57d6799cf7a 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/util/TableTruncator.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/util/TableTruncator.java
@@ -100,7 +100,7 @@ public class TableTruncator {
* @param content table content
* @return array of column widths
*/
- int[] calculateColumnWidths(String[] header, Object[][] content) {
+ public int[] calculateColumnWidths(String[] header, Object[][] content) {
int columnCount = header.length;
int[] maxContentWidths = new int[columnCount];
@@ -233,7 +233,7 @@ public class TableTruncator {
* @param maxWidth maximum width
* @return truncated value
*/
- static String truncateCell(Object value, int maxWidth) {
+ public static String truncateCell(Object value, int maxWidth) {
if (value == null) {
return "null";
}
diff --git
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/PagedSqlResultTest.java
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/PagedSqlResultTest.java
new file mode 100644
index 00000000000..3c508c6a205
--- /dev/null
+++
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/PagedSqlResultTest.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.ignite.internal.cli.sql.table.Table;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link PagedSqlResult}.
+ */
+class PagedSqlResultTest extends BaseIgniteAbstractTest {
+
+ @Test
+ void testFetchSinglePage() throws SQLException {
+ // Create mock with 5 rows, page size 10
+ Statement stmt = createMockStatement(5, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ assertTrue(result.hasResultSet());
+
+ Table<String> page1 = result.fetchNextPage();
+ assertNotNull(page1);
+ assertEquals(5, page1.getRowCount());
+ assertFalse(page1.hasMoreRows(), "Should not have more rows when
all fit in one page");
+
+ Table<String> page2 = result.fetchNextPage();
+ assertNull(page2, "Should be null when no more pages");
+
+ assertFalse(result.hasMoreRows());
+ assertEquals(5, result.getTotalRowsFetched());
+ }
+ }
+
+ @Test
+ void testFetchMultiplePages() throws SQLException {
+ // Create mock with 25 rows, page size 10
+ Statement stmt = createMockStatement(25, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ assertTrue(result.hasResultSet());
+
+ // Page 1: 10 rows
+ Table<String> page1 = result.fetchNextPage();
+ assertNotNull(page1);
+ assertEquals(10, page1.getRowCount());
+ assertTrue(page1.hasMoreRows(), "Should have more rows");
+ assertEquals(10, result.getTotalRowsFetched());
+
+ // Page 2: 10 rows
+ Table<String> page2 = result.fetchNextPage();
+ assertNotNull(page2);
+ assertEquals(10, page2.getRowCount());
+ assertTrue(page2.hasMoreRows(), "Should have more rows");
+ assertEquals(20, result.getTotalRowsFetched());
+
+ // Page 3: 5 rows (last page)
+ Table<String> page3 = result.fetchNextPage();
+ assertNotNull(page3);
+ assertEquals(5, page3.getRowCount());
+ assertFalse(page3.hasMoreRows(), "Last page should not have more
rows");
+ assertEquals(25, result.getTotalRowsFetched());
+
+ // No more pages
+ Table<String> page4 = result.fetchNextPage();
+ assertNull(page4);
+ assertFalse(result.hasMoreRows());
+ }
+ }
+
+ @Test
+ void testExactPageBoundary() throws SQLException {
+ // Create mock with exactly 20 rows, page size 10
+ Statement stmt = createMockStatement(20, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ // Page 1: 10 rows
+ Table<String> page1 = result.fetchNextPage();
+ assertNotNull(page1);
+ assertEquals(10, page1.getRowCount());
+ assertTrue(page1.hasMoreRows());
+
+ // Page 2: 10 rows (exactly at boundary)
+ Table<String> page2 = result.fetchNextPage();
+ assertNotNull(page2);
+ assertEquals(10, page2.getRowCount());
+ assertFalse(page2.hasMoreRows(), "Last page at exact boundary
should not have more rows");
+
+ // No more pages
+ assertNull(result.fetchNextPage());
+ assertEquals(20, result.getTotalRowsFetched());
+ }
+ }
+
+ @Test
+ void testSingleRowPage() throws SQLException {
+ // Create mock with 3 rows, page size 1
+ Statement stmt = createMockStatement(3, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 1,
System.currentTimeMillis())) {
+ Table<String> page1 = result.fetchNextPage();
+ assertEquals(1, page1.getRowCount());
+ assertTrue(page1.hasMoreRows());
+
+ Table<String> page2 = result.fetchNextPage();
+ assertEquals(1, page2.getRowCount());
+ assertTrue(page2.hasMoreRows());
+
+ Table<String> page3 = result.fetchNextPage();
+ assertEquals(1, page3.getRowCount());
+ assertFalse(page3.hasMoreRows());
+
+ assertNull(result.fetchNextPage());
+ assertEquals(3, result.getTotalRowsFetched());
+ }
+ }
+
+ @Test
+ void testEmptyResultSet() throws SQLException {
+ // Create mock with 0 rows
+ Statement stmt = createMockStatement(0, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ assertTrue(result.hasResultSet());
+
+ Table<String> page = result.fetchNextPage();
+ assertNull(page, "Should return null for empty result set");
+
+ assertEquals(0, result.getTotalRowsFetched());
+ assertFalse(result.hasMoreRows());
+ }
+ }
+
+ @Test
+ void testUpdateCount() throws SQLException {
+ Statement stmt = mock(Statement.class);
+ when(stmt.getResultSet()).thenReturn(null);
+ when(stmt.getUpdateCount()).thenReturn(42);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ assertFalse(result.hasResultSet(), "Update statement should not
have result set");
+ assertEquals(42, result.getUpdateCount());
+ }
+ }
+
+ @Test
+ void testColumnNames() throws SQLException {
+ Statement stmt = createMockStatement(5, 3);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10,
System.currentTimeMillis())) {
+ List<String> columnNames = result.getColumnNames();
+ assertNotNull(columnNames);
+ assertEquals(3, columnNames.size());
+ assertEquals("col1", columnNames.get(0));
+ assertEquals("col2", columnNames.get(1));
+ assertEquals("col3", columnNames.get(2));
+ }
+ }
+
+ @Test
+ void testDurationTracking() throws SQLException {
+ long startTime = System.currentTimeMillis();
+ Statement stmt = createMockStatement(5, 2);
+
+ try (PagedSqlResult result = new PagedSqlResult(stmt, 10, startTime)) {
+ // Sleep a bit to ensure time passes
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ long duration = result.getDurationMs();
+ assertTrue(duration >= 10, "Duration should be at least 10ms");
+ }
+ }
+
+ /**
+ * Creates a mock Statement with a ResultSet containing the specified
number of rows and columns.
+ */
+ private Statement createMockStatement(int numRows, int numCols) throws
SQLException {
+ Statement stmt = mock(Statement.class);
+ ResultSet rs = createMockResultSet(numRows, numCols);
+
+ when(stmt.getResultSet()).thenReturn(rs);
+ when(stmt.getMoreResults()).thenReturn(false);
+ when(stmt.getUpdateCount()).thenReturn(-1);
+
+ return stmt;
+ }
+
+ /**
+ * Creates a mock ResultSet with the specified number of rows and columns.
+ */
+ private ResultSet createMockResultSet(int numRows, int numCols) throws
SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ ResultSetMetaData metaData = mock(ResultSetMetaData.class);
+
+ when(rs.getMetaData()).thenReturn(metaData);
+ when(metaData.getColumnCount()).thenReturn(numCols);
+
+ for (int i = 1; i <= numCols; i++) {
+ when(metaData.getColumnLabel(i)).thenReturn("col" + i);
+ }
+
+ AtomicInteger currentRow = new AtomicInteger(0);
+
+ when(rs.next()).thenAnswer(inv -> currentRow.incrementAndGet() <=
numRows);
+
+ when(rs.getString(anyInt())).thenAnswer(inv -> {
+ int colIndex = inv.getArgument(0);
+ return "row" + currentRow.get() + "_col" + colIndex;
+ });
+
+ return rs;
+ }
+}
diff --git
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRendererTest.java
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRendererTest.java
new file mode 100644
index 00000000000..806d26ad739
--- /dev/null
+++
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/StreamingTableRendererTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.sql.table;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.startsWith;
+
+import com.jakewharton.fliptables.FlipTable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link StreamingTableRenderer}.
+ */
+class StreamingTableRendererTest extends BaseIgniteAbstractTest {
+
+ @Test
+ void headerAndFooterMatchFlipTableForSingleRow() {
+ assertMatchesFlipTable(
+ new String[]{"ID", "NAME"},
+ new String[][]{{"1", "Alice"}}
+ );
+ }
+
+ @Test
+ void multipleRowsMatchFlipTable() {
+ assertMatchesFlipTable(
+ new String[]{"ID", "NAME"},
+ new String[][]{{"1", "Alice"}, {"2", "Bob"}}
+ );
+ }
+
+ @Test
+ void singleColumnTable() {
+ assertMatchesFlipTable(
+ new String[]{"VALUE"},
+ new String[][]{{"hello"}, {"world"}}
+ );
+ }
+
+ @Test
+ void manyColumns() {
+ assertMatchesFlipTable(
+ new String[]{"A", "B", "C", "D"},
+ new String[][]{{"1", "22", "333", "4444"}, {"a", "bb", "ccc",
"dddd"}}
+ );
+ }
+
+ @Test
+ void cellPaddingWithVaryingWidths() {
+ assertMatchesFlipTable(
+ new String[]{"X", "LONG_HEADER"},
+ new String[][]{{"1", "short"}}
+ );
+ }
+
+ @Test
+ void cellTruncationToLockedWidth() {
+ int[] widths = {3, 3};
+ String[] headers = {"ID", "NAM"};
+ StreamingTableRenderer renderer = new StreamingTableRenderer(headers,
widths);
+
+ // Row with content wider than locked width - should be truncated by
pad()
+ String row = renderer.renderRow(new Object[]{"1234", "Alice"}, true);
+ // "1234" truncated to "123", "Alice" truncated to "Ali"
+ assertThat(row, containsString(" 123 "));
+ assertThat(row, containsString(" Ali "));
+ }
+
+ @Test
+ void nullCellsRenderedAsEmpty() {
+ int[] widths = {2, 4};
+ String[] headers = {"ID", "NAME"};
+ StreamingTableRenderer renderer = new StreamingTableRenderer(headers,
widths);
+
+ String row = renderer.renderRow(new Object[]{null, "test"}, true);
+ // null renders as empty string padded to width; "test" fills its
4-char column exactly
+ assertThat(row, containsString(" test "));
+ // The null cell should produce empty content (only spaces between
borders)
+ // Row format: ║ <empty padded to 2> │ test ║
+ assertThat(row, containsString("║ │"));
+ }
+
+ @Test
+ void rowSeparatorOnlyBetweenRows() {
+ int[] widths = {2};
+ String[] headers = {"ID"};
+ StreamingTableRenderer renderer = new StreamingTableRenderer(headers,
widths);
+
+ String firstRow = renderer.renderRow(new Object[]{"1"}, true);
+ String secondRow = renderer.renderRow(new Object[]{"2"}, false);
+
+ // First row has no separator line
+ assertThat(firstRow.split("\n").length, equalTo(1));
+ assertThat(firstRow, containsString(" 1 "));
+
+ // Second row has separator + data (2 lines)
+ assertThat(secondRow.split("\n").length, equalTo(2));
+ // First line is the row separator with ─ chars
+ assertThat(secondRow.split("\n")[0], containsString("─"));
+ // Second line is the data row
+ assertThat(secondRow.split("\n")[1], containsString(" 2 "));
+ }
+
+ @Test
+ void headerContainsTopBorderAndSeparator() {
+ int[] widths = {2, 4};
+ String[] headers = {"ID", "NAME"};
+ StreamingTableRenderer renderer = new StreamingTableRenderer(headers,
widths);
+
+ String header = renderer.renderHeader();
+ String[] lines = header.split("\n");
+
+ // Header has 3 lines: top border, header row, separator
+ assertThat(lines.length, equalTo(3));
+
+ // Top border starts with ╔ and ends with ╗
+ assertThat(lines[0], startsWith("╔"));
+ assertThat(lines[0].charAt(lines[0].length() - 1), equalTo('╗'));
+
+ // Header row contains column names
+ assertThat(lines[1], containsString("ID"));
+ assertThat(lines[1], containsString("NAME"));
+
+ // Separator starts with ╠ and ends with ╣
+ assertThat(lines[2], startsWith("╠"));
+ assertThat(lines[2].charAt(lines[2].length() - 1), equalTo('╣'));
+ }
+
+ @Test
+ void footerIsBottomBorder() {
+ int[] widths = {2, 4};
+ String[] headers = {"ID", "NAME"};
+ StreamingTableRenderer renderer = new StreamingTableRenderer(headers,
widths);
+
+ String footer = renderer.renderFooter();
+ String line = footer.trim();
+
+ // Bottom border starts with ╚ and ends with ╝
+ assertThat(line, startsWith("╚"));
+ assertThat(line.charAt(line.length() - 1), equalTo('╝'));
+ }
+
+ @Test
+ void fullTableMatchesFlipTableWithThreeRows() {
+ assertMatchesFlipTable(
+ new String[]{"ID", "NAME", "AGE"},
+ new String[][]{
+ {"1", "Alice", "30"},
+ {"2", "Bob", "25"},
+ {"3", "Charlie", "35"}
+ }
+ );
+ }
+
+ /**
+ * Asserts that the streaming renderer produces the same output as
FlipTable
+ * when using FlipTable's own column widths.
+ */
+ private static void assertMatchesFlipTable(String[] headers, String[][]
data) {
+ String flipTableOutput = FlipTable.of(headers, data);
+ int[] widths = widthsFromFlipTableOutput(flipTableOutput);
+ String actual = renderAll(headers, data, widths);
+ assertThat(actual, equalTo(flipTableOutput));
+ }
+
+ /**
+ * Extracts column widths from FlipTable's rendered output by parsing the
top border line.
+ *
+ * <p>Example: {@code ╔════╤═══════╗} produces widths [2, 5] (segment
length minus 2 padding spaces per column).
+ */
+ private static int[] widthsFromFlipTableOutput(String flipTableOutput) {
+ String topBorder = flipTableOutput.split("\n")[0];
+ List<Integer> widths = new ArrayList<>();
+ int segmentLen = 0;
+
+ for (int i = 1; i < topBorder.length(); i++) {
+ char c = topBorder.charAt(i);
+ if (c == '╤' || c == '╗') {
+ widths.add(segmentLen - 2); // subtract 2 padding spaces
+ segmentLen = 0;
+ } else {
+ segmentLen++;
+ }
+ }
+ return widths.stream().mapToInt(Integer::intValue).toArray();
+ }
+
+ /**
+ * Renders a full table using the streaming renderer and returns the
combined output.
+ */
+ private static String renderAll(String[] headers, String[][] data, int[]
widths) {
+ String[] headersCopy = Arrays.copyOf(headers, headers.length);
+ StreamingTableRenderer renderer = new
StreamingTableRenderer(headersCopy, widths);
+ StringBuilder sb = new StringBuilder();
+ sb.append(renderer.renderHeader());
+ for (int i = 0; i < data.length; i++) {
+ sb.append(renderer.renderRow(data[i], i == 0));
+ }
+ sb.append(renderer.renderFooter());
+ return sb.toString();
+ }
+}
diff --git
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/TableTest.java
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/TableTest.java
index 21779e2d7a4..1a8bf940023 100644
---
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/TableTest.java
+++
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/sql/table/TableTest.java
@@ -17,12 +17,20 @@
package org.apache.ignite.internal.cli.sql.table;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
-class TableTest {
+class TableTest extends BaseIgniteAbstractTest {
@Test
public void headerTest() {
@@ -42,4 +50,80 @@ class TableTest {
Assertions.assertArrayEquals(table.header(), new String[] { "EMPTY" });
Assertions.assertArrayEquals(table.content(), new Object[0][0]);
}
+
+ @Test
+ public void rowCountTest() {
+ Table<String> table = new Table<>(List.of("col1", "col2"),
List.of("a", "b", "c", "d"));
+ Assertions.assertEquals(2, table.getRowCount());
+ Assertions.assertFalse(table.hasMoreRows());
+ }
+
+ @Test
+ public void hasMoreRowsTrueTest() {
+ Table<String> table = new Table<>(List.of("col1"), List.of("a", "b",
"c"), true);
+ Assertions.assertEquals(3, table.getRowCount());
+ Assertions.assertTrue(table.hasMoreRows());
+ }
+
+ @Test
+ public void hasMoreRowsFalseTest() {
+ Table<String> table = new Table<>(List.of("col1"), List.of("a", "b",
"c"), false);
+ Assertions.assertEquals(3, table.getRowCount());
+ Assertions.assertFalse(table.hasMoreRows());
+ }
+
+ @Test
+ public void fromResultSet() throws SQLException {
+ ResultSet rs = createMockResultSet(5, 2);
+
+ Table<String> table = Table.fromResultSet(rs);
+
+ Assertions.assertEquals(5, table.getRowCount());
+ Assertions.assertFalse(table.hasMoreRows());
+ Assertions.assertArrayEquals(new String[]{"col1", "col2"},
table.header());
+ }
+
+ @Test
+ public void fromResultSetEmptyResultSet() throws SQLException {
+ ResultSet rs = createMockResultSet(0, 2);
+
+ Table<String> table = Table.fromResultSet(rs);
+
+ Assertions.assertEquals(0, table.getRowCount());
+ Assertions.assertFalse(table.hasMoreRows());
+ }
+
+ /**
+ * Creates a mock ResultSet with the specified number of rows and columns.
+ */
+ private ResultSet createMockResultSet(int numRows, int numCols) throws
SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ ResultSetMetaData metaData = mock(ResultSetMetaData.class);
+
+ when(rs.getMetaData()).thenReturn(metaData);
+ when(metaData.getColumnCount()).thenReturn(numCols);
+
+ for (int i = 1; i <= numCols; i++) {
+ when(metaData.getColumnLabel(i)).thenReturn("col" + i);
+ }
+
+ // Track current row position
+ AtomicInteger currentRow = new AtomicInteger(0);
+
+ when(rs.next()).thenAnswer(invocation -> {
+ int row = currentRow.incrementAndGet();
+ return row <= numRows;
+ });
+
+ // Return cell values based on row/column
+ for (int col = 1; col <= numCols; col++) {
+ final int colNum = col;
+ when(rs.getString(colNum)).thenAnswer(invocation -> {
+ int row = currentRow.get();
+ return "r" + row + "c" + colNum;
+ });
+ }
+
+ return rs;
+ }
}