diff --git a/lib/cli/Table.php b/lib/cli/Table.php index b3bfed7..fba31a0 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( iterable $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( iterable $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..5b6db37 100644 --- a/tests/Test_Table.php +++ b/tests/Test_Table.php @@ -524,4 +524,113 @@ 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] ); + } + + 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 ); + } + } + } }