From 65e79c75d5b80742eff04213383888864305afd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:17:45 +0000 Subject: [PATCH 1/2] Add a way to display table rows from an iterator Add `Table::getDisplayLinesFromRows()` and `Table::displayRows()`, which display the table with rows from an iterable added. If the renderer doesn't need the column widths, which `Renderer::needsWidths()` now tells, the lines of each row are yielded as soon as the row is read, so the rows don't have to be held in memory. That is the case for the tabular renderer used when STDOUT is piped. Otherwise the rows are added first, as before. `getDisplayLines()` now shares the code for the header, row and footer lines with the new method, so both give the same lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- lib/cli/Table.php | 109 ++++++++++++++++++++++++++++++++++--- lib/cli/table/Renderer.php | 11 ++++ lib/cli/table/Tabular.php | 9 +++ tests/Test_Table.php | 88 ++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) diff --git a/lib/cli/Table.php b/lib/cli/Table.php index b3bfed7..8dc77e8 100644 --- a/lib/cli/Table.php +++ b/lib/cli/Table.php @@ -220,6 +220,86 @@ public function displayRow( array $row ) { * @return array */ public function getDisplayLines() { + $out = $this->getHeaderLines(); + + foreach ( $this->_rows as $row ) { + // Append line by line; array_merge() in this loop copies $out for every row. + foreach ( $this->getRowLines( $row ) as $line ) { + $out[] = $line; + } + } + + foreach ( $this->getFooterLines( ! empty( $this->_rows ) ) as $line ) { + $out[] = $line; + } + return $out; + } + + /** + * Get the table lines to output for the rows of the table followed by the given rows. + * + * The lines are the same as those of `getDisplayLines()` after adding the rows. If the + * renderer doesn't need the column widths, as is the case for the tabular renderer used + * when STDOUT is piped, the lines of each row are yielded as soon as the row is read, so + * the rows don't all have to be held in memory. Otherwise, the rows are added first. + * + * @param iterable> $rows Rows to display after those of the table. + * @return \Generator + */ + public function getDisplayLinesFromRows( $rows ) { + if ( $this->_renderer->needsWidths() ) { + foreach ( $rows as $row ) { + $this->addRow( $row ); + } + foreach ( $this->getDisplayLines() as $line ) { + yield $line; + } + return; + } + + foreach ( $this->getHeaderLines() as $line ) { + yield $line; + } + + $has_rows = false; + foreach ( $this->_rows as $row ) { + $has_rows = true; + foreach ( $this->getRowLines( $row ) as $line ) { + yield $line; + } + } + foreach ( $rows as $row ) { + $has_rows = true; + foreach ( $this->getRowLines( $row ) as $line ) { + yield $line; + } + } + + foreach ( $this->getFooterLines( $has_rows ) as $line ) { + yield $line; + } + } + + /** + * Output the table to `STDOUT` with the given rows added, without holding them all in memory if possible. + * + * @see cli\Table::getDisplayLinesFromRows() + * + * @param iterable> $rows Rows to display after those of the table. + * @return void + */ + public function displayRows( $rows ) { + foreach ( $this->getDisplayLinesFromRows( $rows ) as $line ) { + Streams::line( $line ); + } + } + + /** + * Get the lines before the rows: the header row and its borders. + * + * @return array + */ + private function getHeaderLines() { $this->_renderer->setWidths( $this->_width, $fallback = true ); $this->_renderer->setHeaders( $this->_headers ); $this->_renderer->setAlignments( $this->_alignments ); @@ -233,16 +313,31 @@ public function getDisplayLines() { if ( isset( $border ) ) { $out[] = $border; } + return $out; + } - foreach ( $this->_rows as $row ) { - // Append line by line; array_merge() in this loop copies $out for every row. - foreach ( explode( PHP_EOL, $this->_renderer->row( $row ) ) as $line ) { - $out[] = $line; - } - } + /** + * Get the lines of a row. + * + * @param array $row The row. + * @return array + */ + private function getRowLines( array $row ) { + return explode( PHP_EOL, $this->_renderer->row( $row ) ); + } + + /** + * Get the lines after the rows: the final border and the footer row. + * + * @param bool $has_rows Whether the table has rows. + * @return array + */ + private function getFooterLines( $has_rows ) { + $border = $this->_renderer->border(); + $out = array(); // Only add final border if there are rows - if ( ! empty( $this->_rows ) && isset( $border ) ) { + if ( $has_rows && isset( $border ) ) { $out[] = $border; } diff --git a/lib/cli/table/Renderer.php b/lib/cli/table/Renderer.php index 10aa85a..df933c5 100644 --- a/lib/cli/table/Renderer.php +++ b/lib/cli/table/Renderer.php @@ -42,6 +42,17 @@ public function __construct(array $widths = array(), array $alignments = array() $this->setAlignments($alignments); } + /** + * Whether the renderer needs the widths of the columns to render a row. + * + * If it doesn't, rows can be rendered as they are read, without knowing all of them. + * + * @return bool + */ + public function needsWidths() { + return true; + } + /** * Set the alignments of each column in the table. * diff --git a/lib/cli/table/Tabular.php b/lib/cli/table/Tabular.php index f373799..3ab4b8b 100644 --- a/lib/cli/table/Tabular.php +++ b/lib/cli/table/Tabular.php @@ -16,6 +16,15 @@ * The tabular renderer is used for displaying data in a tabular format. */ class Tabular extends Renderer { + /** + * The tabular renderer doesn't align the columns, so it doesn't need their widths. + * + * @return bool + */ + public function needsWidths() { + return false; + } + /** * Renders a row for output. * diff --git a/tests/Test_Table.php b/tests/Test_Table.php index ab01cbb..6d83a16 100644 --- a/tests/Test_Table.php +++ b/tests/Test_Table.php @@ -524,4 +524,92 @@ public function test_displayRow_tabular() { } } } + + public function test_renderers_declare_whether_they_need_widths() { + $this->assertTrue( ( new cli\Table\Ascii() )->needsWidths() ); + $this->assertFalse( ( new cli\Table\Tabular() )->needsWidths() ); + } + + /** + * @return array + */ + public static function data_renderers() { + return [ + 'ascii' => [ new cli\Table\Ascii() ], + 'tabular' => [ new cli\Table\Tabular() ], + ]; + } + + /** + * @dataProvider data_renderers + */ + #[\PHPUnit\Framework\Attributes\DataProvider( 'data_renderers' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function test_display_lines_from_rows_match_display_lines( $renderer ) { + $rows = [ + [ 'first', "multi\nline" ], + [ 'second', '' ], + [ 'third', 'a very long value' ], + ]; + + foreach ( [ 'with footers' => true, 'without footers' => false ] as $case => $with_footers ) { + foreach ( [ 0, 1, 3 ] as $count ) { + // One row is already in the table, the others are passed as an iterator. + $build = function () use ( $renderer, $with_footers, $count, $rows ) { + $table = new cli\Table(); + $table->setRenderer( clone $renderer ); + $table->setHeaders( [ 'Name', 'Value' ] ); + if ( $with_footers ) { + $table->setFooters( [ 'Total', (string) $count ] ); + } + if ( $count > 0 ) { + $table->addRow( $rows[0] ); + } + return $table; + }; + + $expected_table = $build(); + for ( $i = 1; $i < $count; $i++ ) { + $expected_table->addRow( $rows[ $i ] ); + } + + $lines = iterator_to_array( + $build()->getDisplayLinesFromRows( new ArrayIterator( array_slice( $rows, 1, max( 0, $count - 1 ) ) ) ), + false + ); + + $this->assertSame( $expected_table->getDisplayLines(), $lines, "$case, $count rows" ); + } + } + } + + public function test_display_lines_from_rows_are_yielded_as_rows_are_read_with_tabular_renderer() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Tabular() ); + $table->setHeaders( [ 'Name' ] ); + + $log = []; + $rows = ( function () use ( &$log ) { + foreach ( [ 'a', 'b' ] as $name ) { + $log[] = "read $name"; + yield [ $name ]; + } + } )(); + + foreach ( $table->getDisplayLinesFromRows( $rows ) as $line ) { + $log[] = "line $line"; + } + + $this->assertSame( [ 'line Name', 'read a', 'line a', 'read b', 'line b' ], $log ); + } + + public function test_display_lines_from_rows_are_aligned_with_ascii_renderer() { + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Ascii() ); + $table->setHeaders( [ 'Name' ] ); + + $lines = iterator_to_array( $table->getDisplayLinesFromRows( new ArrayIterator( [ [ 'a' ], [ 'longer' ] ] ) ), false ); + + // The first row is padded to the width of the second one. + $this->assertSame( '| a |', $lines[3] ); + } } From 73f03520cf589d5470817c366fbbf7f518b36a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:30:47 +0000 Subject: [PATCH 2/2] Type the rows of the new table methods as iterable Also test that displayRows() writes the display lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D26yjkN2BiqCXT6p6o1WqS --- lib/cli/Table.php | 4 ++-- tests/Test_Table.php | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/cli/Table.php b/lib/cli/Table.php index 8dc77e8..fba31a0 100644 --- a/lib/cli/Table.php +++ b/lib/cli/Table.php @@ -246,7 +246,7 @@ public function getDisplayLines() { * @param iterable> $rows Rows to display after those of the table. * @return \Generator */ - public function getDisplayLinesFromRows( $rows ) { + public function getDisplayLinesFromRows( iterable $rows ) { if ( $this->_renderer->needsWidths() ) { foreach ( $rows as $row ) { $this->addRow( $row ); @@ -288,7 +288,7 @@ public function getDisplayLinesFromRows( $rows ) { * @param iterable> $rows Rows to display after those of the table. * @return void */ - public function displayRows( $rows ) { + public function displayRows( iterable $rows ) { foreach ( $this->getDisplayLinesFromRows( $rows ) as $line ) { Streams::line( $line ); } diff --git a/tests/Test_Table.php b/tests/Test_Table.php index 6d83a16..5b6db37 100644 --- a/tests/Test_Table.php +++ b/tests/Test_Table.php @@ -612,4 +612,25 @@ public function test_display_lines_from_rows_are_aligned_with_ascii_renderer() { // The first row is padded to the width of the second one. $this->assertSame( '| a |', $lines[3] ); } + + public function test_display_rows_writes_the_display_lines() { + $mock_file = tempnam( sys_get_temp_dir(), 'temp' ); + $resource = fopen( $mock_file, 'wb' ); + + try { + \cli\Streams::setStream( 'out', $resource ); + + $table = new cli\Table(); + $table->setRenderer( new cli\Table\Tabular() ); + $table->setHeaders( [ 'Name', 'Age' ] ); + $table->displayRows( new ArrayIterator( [ [ 'Alice', '30' ], [ 'Bob', '25' ] ] ) ); + + $this->assertSame( "Name\tAge\nAlice\t30\nBob\t25\n", file_get_contents( $mock_file ) ); + } finally { + \cli\Streams::setStream( 'out', STDOUT ); + if ( $mock_file && file_exists( $mock_file ) ) { + unlink( $mock_file ); + } + } + } }