diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4e5ec3..a60dd299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), #### Changed * [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image. +* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2. #### Deprecated diff --git a/composer.json b/composer.json index ce688a63..0a7bd6c2 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,10 @@ "mikehaertl/phpwkhtmltopdf": "^2.2", "monolog/monolog": "^1.21", "roave/security-advisories": "dev-master", - "symfony/console": "^4.1", - "symfony/filesystem": "^4.1", - "symfony/lock": "^4.1", - "symfony/process": "^4.1", + "symfony/console": "^4.2", + "symfony/filesystem": "^4.2", + "symfony/lock": "^4.2", + "symfony/process": "^4.2", "theorchard/monolog-cascade": "^0.4", "zendframework/zend-config": "^3.0", "zendframework/zend-config-aggregator": "^1.0", @@ -57,8 +57,8 @@ "phpunit/phpcov": "^5.0", "phpunit/phpunit": "^7.3", "shlinkio/php-coding-standard": "~1.0.0", - "symfony/dotenv": "^4.0", - "symfony/var-dumper": "^4.0", + "symfony/dotenv": "^4.2", + "symfony/var-dumper": "^4.2", "zendframework/zend-component-installer": "^2.1", "zendframework/zend-expressive-tooling": "^1.0" }, diff --git a/func_tests_bootstrap.php b/func_tests_bootstrap.php index 6a4300f3..5f44c7c5 100644 --- a/func_tests_bootstrap.php +++ b/func_tests_bootstrap.php @@ -26,7 +26,7 @@ $config['entity_manager']['connection'] = [ $sm->setService('config', $config); // Create database -$process = new Process('vendor/bin/doctrine orm:schema-tool:create --no-interaction -q --test', __DIR__); +$process = new Process(['vendor/bin/doctrine', 'orm:schema-tool:create', '--no-interaction', '-q', '--test'], __DIR__); $process->inheritEnvironmentVariables() ->mustRun(); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 3eb96e96..42c8fb73 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -3,13 +3,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; +use Shlinkio\Shlink\Common\Console\ShlinkTable; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; use function array_filter; use function array_map; use function sprintf; @@ -46,7 +46,6 @@ class ListKeysCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): void { - $io = new SymfonyStyle($input, $output); $enabledOnly = $input->getOption('enabledOnly'); $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) { @@ -62,7 +61,7 @@ class ListKeysCommand extends Command return $rowData; }, $this->apiKeyService->listKeys($enabledOnly)); - $io->table(array_filter([ + ShlinkTable::fromOutput($output)->render(array_filter([ 'Key', ! $enabledOnly ? 'Is enabled' : null, 'Expiration date', diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 48c3a255..7c2989db 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; +use Shlinkio\Shlink\Common\Console\ShlinkTable; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\VisitsParams; @@ -69,7 +70,6 @@ class GetVisitsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): void { - $io = new SymfonyStyle($input, $output); $shortCode = $input->getArgument('shortCode'); $startDate = $this->getDateOption($input, 'startDate'); $endDate = $this->getDateOption($input, 'endDate'); @@ -82,7 +82,7 @@ class GetVisitsCommand extends Command $rowData['country'] = $visit->getVisitLocation()->getCountryName(); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); }, $visits); - $io->table(['Referer', 'Date', 'User agent', 'Country'], $rows); + ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); } private function getDateOption(InputInterface $input, $key) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 2bef076d..a5e83c85 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -3,8 +3,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\Common\Console\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; +use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Symfony\Component\Console\Command\Command; @@ -12,6 +14,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Zend\Paginator\Paginator; use function array_values; use function count; use function explode; @@ -78,39 +81,56 @@ class ListShortUrlsCommand extends Command $searchTerm = $input->getOption('searchTerm'); $tags = $input->getOption('tags'); $tags = ! empty($tags) ? explode(',', $tags) : []; - $showTags = $input->getOption('showTags'); + $showTags = (bool) $input->getOption('showTags'); $transformer = new ShortUrlDataTransformer($this->domainConfig); do { - $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); + $result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer); $page++; - $headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count']; - if ($showTags) { - $headers[] = 'Tags'; - } - - $rows = []; - foreach ($result as $row) { - $shortUrl = $transformer->transform($row); - if ($showTags) { - $shortUrl['tags'] = implode(', ', $shortUrl['tags']); - } else { - unset($shortUrl['tags']); - } - - unset($shortUrl['originalUrl']); - $rows[] = array_values($shortUrl); - } - $io->table($headers, $rows); - - if ($this->isLastPage($result)) { - $continue = false; - $io->success('Short URLs properly listed'); - } else { - $continue = $io->confirm(sprintf('Continue with page %s?', $page), false); - } + $continue = $this->isLastPage($result) + ? false + : $io->confirm(sprintf('Continue with page %s?', $page), false); } while ($continue); + + $io->newLine(); + $io->success('Short URLs properly listed'); + } + + private function renderPage( + InputInterface $input, + OutputInterface $output, + int $page, + ?string $searchTerm, + array $tags, + bool $showTags, + DataTransformerInterface $transformer + ): Paginator { + $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); + + $headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count']; + if ($showTags) { + $headers[] = 'Tags'; + } + + $rows = []; + foreach ($result as $row) { + $shortUrl = $transformer->transform($row); + if ($showTags) { + $shortUrl['tags'] = implode(', ', $shortUrl['tags']); + } else { + unset($shortUrl['tags']); + } + + unset($shortUrl['originalUrl']); + $rows[] = array_values($shortUrl); + } + + ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage( + $result, + 'Page %s of %s' + )); + return $result; } private function processOrderBy(InputInterface $input) diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index f0aa66f8..4d2e05bb 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -3,12 +3,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; +use Shlinkio\Shlink\Common\Console\ShlinkTable; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; use function Functional\map; class ListTagsCommand extends Command @@ -33,8 +33,7 @@ class ListTagsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): void { - $io = new SymfonyStyle($input, $output); - $io->table(['Name'], $this->getTagsRows()); + ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows()); } private function getTagsRows(): array diff --git a/module/Common/src/Console/ShlinkTable.php b/module/Common/src/Console/ShlinkTable.php new file mode 100644 index 00000000..1ded8334 --- /dev/null +++ b/module/Common/src/Console/ShlinkTable.php @@ -0,0 +1,41 @@ + %s '; + + /** @var Table|null */ + private $baseTable; + + public function __construct(Table $baseTable) + { + $this->baseTable = $baseTable; + } + + public static function fromOutput(OutputInterface $output): self + { + return new self(new Table($output)); + } + + public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void + { + $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); + $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) + ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); + + $table = clone $this->baseTable; + $table->setStyle($style) + ->setHeaders($headers) + ->setRows($rows) + ->setFooterTitle($footerTitle) + ->setHeaderTitle($headerTitle) + ->render(); + } +} diff --git a/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php b/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php index 85fcc861..a98568d3 100644 --- a/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php +++ b/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php @@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Zend\Paginator\Paginator; use Zend\Stdlib\ArrayUtils; use function array_map; +use function sprintf; trait PaginatorUtilsTrait { @@ -39,4 +40,9 @@ trait PaginatorUtilsTrait { return $paginator->getCurrentPageNumber() >= $paginator->count(); } + + private function formatCurrentPageMessage(Paginator $paginator, string $pattern): string + { + return sprintf($pattern, $paginator->getCurrentPageNumber(), $paginator->count()); + } } diff --git a/module/Common/test/Console/ShlinkTableTest.php b/module/Common/test/Console/ShlinkTableTest.php new file mode 100644 index 00000000..d38fb5bb --- /dev/null +++ b/module/Common/test/Console/ShlinkTableTest.php @@ -0,0 +1,70 @@ +baseTable = $this->prophesize(Table::class); + $this->shlinkTable = new ShlinkTable($this->baseTable->reveal()); + } + + /** + * @test + */ + public function renderMakesTableToBeRenderedWithProvidedInfo() + { + $headers = []; + $rows = [[]]; + $headerTitle = 'Header'; + $footerTitle = 'Footer'; + + $setStyle = $this->baseTable->setStyle(Argument::type(TableStyle::class))->willReturn( + $this->baseTable->reveal() + ); + $setHeaders = $this->baseTable->setHeaders($headers)->willReturn($this->baseTable->reveal()); + $setRows = $this->baseTable->setRows($rows)->willReturn($this->baseTable->reveal()); + $setFooterTitle = $this->baseTable->setFooterTitle($footerTitle)->willReturn($this->baseTable->reveal()); + $setHeaderTitle = $this->baseTable->setHeaderTitle($headerTitle)->willReturn($this->baseTable->reveal()); + $render = $this->baseTable->render()->willReturn($this->baseTable->reveal()); + + $this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle); + + $setStyle->shouldHaveBeenCalledOnce(); + $setHeaders->shouldHaveBeenCalledOnce(); + $setRows->shouldHaveBeenCalledOnce(); + $setFooterTitle->shouldHaveBeenCalledOnce(); + $setHeaderTitle->shouldHaveBeenCalledOnce(); + $render->shouldHaveBeenCalledOnce(); + } + + /** + * @test + */ + public function newTableIsCreatedForFactoryMethod() + { + $instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal()); + + $ref = new ReflectionObject($instance); + $baseTable = $ref->getProperty('baseTable'); + $baseTable->setAccessible(true); + + $this->assertInstanceOf(Table::class, $baseTable->getValue($instance)); + } +} diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index d7c44ff7..f8008f8c 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Exception\WrongIpException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\VisitRepository; +use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; +use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; /** * Class Visit @@ -88,9 +90,9 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } - public function getVisitLocation(): VisitLocation + public function getVisitLocation(): VisitLocationInterface { - return $this->visitLocation; + return $this->visitLocation ?? new UnknownVisitLocation(); } public function locate(VisitLocation $visitLocation): self diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php index f9ee7250..472de2ee 100644 --- a/module/Core/src/Entity/VisitLocation.php +++ b/module/Core/src/Entity/VisitLocation.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Entity; use Doctrine\ORM\Mapping as ORM; -use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; use function array_key_exists; /** @@ -16,7 +16,7 @@ use function array_key_exists; * @ORM\Entity() * @ORM\Table(name="visit_locations") */ -class VisitLocation extends AbstractEntity implements JsonSerializable +class VisitLocation extends AbstractEntity implements VisitLocationInterface { /** * @var string diff --git a/module/Core/src/Visit/Model/UnknownVisitLocation.php b/module/Core/src/Visit/Model/UnknownVisitLocation.php new file mode 100644 index 00000000..118e38bd --- /dev/null +++ b/module/Core/src/Visit/Model/UnknownVisitLocation.php @@ -0,0 +1,47 @@ +json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return [ + 'countryCode' => 'Unknown', + 'countryName' => 'Unknown', + 'regionName' => 'Unknown', + 'cityName' => 'Unknown', + 'latitude' => '0.0', + 'longitude' => '0.0', + 'timezone' => 'Unknown', + ]; + } +} diff --git a/module/Core/src/Visit/Model/VisitLocationInterface.php b/module/Core/src/Visit/Model/VisitLocationInterface.php new file mode 100644 index 00000000..83261334 --- /dev/null +++ b/module/Core/src/Visit/Model/VisitLocationInterface.php @@ -0,0 +1,17 @@ +isUpdate) { $this->io->write('Initializing database...'); if (! $this->execPhp( - 'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create', + ['vendor/doctrine/orm/bin/doctrine.php', 'orm:schema-tool:create'], 'Error generating database.', $output )) { @@ -144,7 +145,7 @@ class InstallCommand extends Command // Run database migrations $this->io->write('Updating database...'); if (! $this->execPhp( - 'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate', + ['vendor/doctrine/migrations/bin/doctrine-migrations.php', 'migrations:migrate'], 'Error updating database.', $output )) { @@ -154,16 +155,16 @@ class InstallCommand extends Command // Generate proxies $this->io->write('Generating proxies...'); if (! $this->execPhp( - 'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies', + ['vendor/doctrine/orm/bin/doctrine.php', 'orm:generate-proxies'], 'Error generating proxies.', $output )) { return; } - // Download GeoLite2 db filte + // Download GeoLite2 db file $this->io->write('Downloading GeoLite2 db...'); - if (! $this->execPhp('bin/cli visit:update-db', 'Error downloading GeoLite2 db.', $output)) { + if (! $this->execPhp(['bin/cli', 'visit:update-db'], 'Error downloading GeoLite2 db.', $output)) { return; } @@ -215,7 +216,7 @@ class InstallCommand extends Command return $config; } - private function execPhp(string $command, string $errorMessage, OutputInterface $output): bool + private function execPhp(array $command, string $errorMessage, OutputInterface $output): bool { if ($this->processHelper === null) { $this->processHelper = $this->getHelper('process'); @@ -225,12 +226,13 @@ class InstallCommand extends Command $this->phpBinary = $this->phpFinder->find(false) ?: 'php'; } + array_unshift($command, $this->phpBinary); $this->io->write( - ' [Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"] ', + ' [Running "' . implode(' ', $command) . '"] ', false, OutputInterface::VERBOSITY_VERBOSE ); - $process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command)); + $process = $this->processHelper->run($output, $command); if ($process->isSuccessful()) { $this->io->writeln(' Success!'); return true;