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;
+    }
 }

Reply via email to