Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 102 additions & 7 deletions lib/cli/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,86 @@ public function displayRow( array $row ) {
* @return array<int, string>
*/
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<array<int, string>> $rows Rows to display after those of the table.
* @return \Generator<int, string>
*/
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<array<int, string>> $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<int, string>
*/
private function getHeaderLines() {
$this->_renderer->setWidths( $this->_width, $fallback = true );
$this->_renderer->setHeaders( $this->_headers );
$this->_renderer->setAlignments( $this->_alignments );
Expand All @@ -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<int, string> $row The row.
* @return array<int, string>
*/
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<int, string>
*/
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;
}

Expand Down
11 changes: 11 additions & 0 deletions lib/cli/table/Renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions lib/cli/table/Tabular.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
109 changes: 109 additions & 0 deletions tests/Test_Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{0: \cli\table\Renderer}>
*/
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 );
}
}
}
}
Loading