Skip to content

Commit 0f33f8b

Browse files
authored
feature: flow:sql:format CLI command (#2292)
* feature: flow:sql:format CLI command * feature: allow to pass options to format command
1 parent 405e486 commit 0f33f8b

File tree

9 files changed

+527
-0
lines changed

9 files changed

+527
-0
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Bridge\Symfony\PostgreSqlBundle\Command;
6+
7+
use function Flow\Filesystem\DSL\{native_local_filesystem, path};
8+
use function Flow\PostgreSql\DSL\{sql_deparse_options, sql_format};
9+
use function Flow\Types\DSL\{type_boolean, type_integer, type_string};
10+
use Flow\Bridge\Symfony\PostgreSqlBundle\Sql\SqlFileFinder;
11+
use Flow\Filesystem\Local\NativeLocalFilesystem;
12+
use Flow\PostgreSql\DeparseOptions;
13+
use Symfony\Component\Console\Attribute\AsCommand;
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\{InputArgument, InputInterface, InputOption};
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
18+
#[AsCommand(name: 'flow:sql:format', description: 'Format SQL using the flow-php/postgresql parser. Accepts a raw SQL string or a --path with glob support.')]
19+
final class FormatSqlCommand extends Command
20+
{
21+
private readonly NativeLocalFilesystem $filesystem;
22+
23+
public function __construct()
24+
{
25+
parent::__construct();
26+
$this->filesystem = native_local_filesystem();
27+
}
28+
29+
protected function configure() : void
30+
{
31+
$this
32+
->addArgument('sql', InputArgument::OPTIONAL, 'Raw SQL string to format. Ignored when --path is used.')
33+
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Path, directory or glob pattern (only files with .sql extension are processed)')
34+
->addOption('write', 'w', InputOption::VALUE_NONE, 'Write formatted SQL back to source files instead of printing to stdout')
35+
->addOption('check', null, InputOption::VALUE_NONE, 'Exit with non-zero status when any input is not already formatted (does not modify files)')
36+
->addOption('indent-size', null, InputOption::VALUE_REQUIRED, 'Number of spaces used for indentation', 4)
37+
->addOption('max-line-length', null, InputOption::VALUE_REQUIRED, 'Maximum line length before wrapping', 80)
38+
->addOption('no-pretty-print', null, InputOption::VALUE_NONE, 'Disable pretty-printing (output single-line SQL)')
39+
->addOption('commas-start-of-line', null, InputOption::VALUE_NONE, 'Place commas at the start of the line instead of the end')
40+
->addOption('trailing-newline', null, InputOption::VALUE_NONE, 'Append a trailing newline to formatted SQL');
41+
}
42+
43+
protected function execute(InputInterface $input, OutputInterface $output) : int
44+
{
45+
$pathOption = $input->getOption('path');
46+
$sqlArgument = $input->getArgument('sql');
47+
$write = type_boolean()->cast($input->getOption('write'));
48+
$check = type_boolean()->cast($input->getOption('check'));
49+
50+
if ($pathOption === null && $sqlArgument === null) {
51+
$output->writeln('<error>Either a SQL string argument or --path option must be provided.</error>');
52+
53+
return Command::FAILURE;
54+
}
55+
56+
$options = $this->buildDeparseOptions($input);
57+
58+
if ($pathOption !== null) {
59+
return $this->formatPath(type_string()->assert($pathOption), $write, $check, $output, $options);
60+
}
61+
62+
$sql = type_string()->assert($sqlArgument);
63+
$formatted = sql_format($sql, $options);
64+
65+
if ($check) {
66+
if (\trim($formatted) !== \trim($sql)) {
67+
$output->writeln('<error>SQL is not formatted.</error>');
68+
69+
return Command::FAILURE;
70+
}
71+
72+
$output->writeln('<info>SQL is already formatted.</info>');
73+
74+
return Command::SUCCESS;
75+
}
76+
77+
$output->writeln($formatted);
78+
79+
return Command::SUCCESS;
80+
}
81+
82+
private function buildDeparseOptions(InputInterface $input) : DeparseOptions
83+
{
84+
return sql_deparse_options()
85+
->indentSize(type_integer()->cast($input->getOption('indent-size')))
86+
->maxLineLength(type_integer()->cast($input->getOption('max-line-length')))
87+
->prettyPrint(!type_boolean()->cast($input->getOption('no-pretty-print')))
88+
->commasStartOfLine(type_boolean()->cast($input->getOption('commas-start-of-line')))
89+
->trailingNewline(type_boolean()->cast($input->getOption('trailing-newline')));
90+
}
91+
92+
private function formatPath(string $pathString, bool $write, bool $check, OutputInterface $output, DeparseOptions $options) : int
93+
{
94+
$files = (new SqlFileFinder(native_local_filesystem()))->find(path($pathString));
95+
96+
if ($files === []) {
97+
$output->writeln(\sprintf('<comment>No .sql files found at: %s</comment>', $pathString));
98+
99+
return Command::SUCCESS;
100+
}
101+
102+
$unformatted = 0;
103+
$formattedCount = 0;
104+
105+
foreach ($files as $file) {
106+
$original = $this->filesystem->readFrom($file)->content();
107+
108+
try {
109+
$formatted = sql_format($original, $options);
110+
} catch (\Throwable $e) {
111+
$output->writeln(\sprintf('<error>Failed to format %s: %s</error>', $file->path(), $e->getMessage()));
112+
113+
return Command::FAILURE;
114+
}
115+
116+
$isDifferent = \trim($formatted) !== \trim($original);
117+
118+
if ($check) {
119+
if ($isDifferent) {
120+
$unformatted++;
121+
$output->writeln(\sprintf('<error>Not formatted: %s</error>', $file->path()));
122+
}
123+
124+
continue;
125+
}
126+
127+
if ($write) {
128+
if ($isDifferent) {
129+
$this->filesystem->writeTo($file)->append($formatted)->close();
130+
$formattedCount++;
131+
$output->writeln(\sprintf('<info>Formatted: %s</info>', $file->path()));
132+
}
133+
134+
continue;
135+
}
136+
137+
$output->writeln(\sprintf('<comment>-- %s</comment>', $file->path()));
138+
$output->writeln($formatted);
139+
$output->writeln('');
140+
}
141+
142+
if ($check) {
143+
if ($unformatted > 0) {
144+
$output->writeln(\sprintf('<error>%d file(s) not formatted.</error>', $unformatted));
145+
146+
return Command::FAILURE;
147+
}
148+
149+
$output->writeln(\sprintf('<info>All %d file(s) are properly formatted.</info>', \count($files)));
150+
151+
return Command::SUCCESS;
152+
}
153+
154+
if ($write) {
155+
$output->writeln(\sprintf('<info>%d file(s) reformatted, %d unchanged.</info>', $formattedCount, \count($files) - $formattedCount));
156+
}
157+
158+
return Command::SUCCESS;
159+
}
160+
}

src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/DependencyInjection/FlowPostgreSqlExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public function load(array $configs, ContainerBuilder $container) : void
6060

6161
$loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
6262
$loader->load('database.php');
63+
$loader->load('format.php');
6364

6465
if ($config['migrations']['enabled']) {
6566
$isFirst = true;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Flow\Bridge\Symfony\PostgreSqlBundle\Command\FormatSqlCommand;
6+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
7+
8+
return static function (ContainerConfigurator $container) : void {
9+
$services = $container->services();
10+
11+
$services->set('flow.postgresql.command.sql_format', FormatSqlCommand::class)
12+
->tag('console.command');
13+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Bridge\Symfony\PostgreSqlBundle\Sql;
6+
7+
use function Flow\Filesystem\DSL\path;
8+
use Flow\Filesystem\Local\NativeLocalFilesystem;
9+
use Flow\Filesystem\Path;
10+
11+
final readonly class SqlFileFinder
12+
{
13+
public function __construct(private NativeLocalFilesystem $filesystem)
14+
{
15+
}
16+
17+
/**
18+
* Resolves a Path (file, directory, or glob) into a list of `.sql` files.
19+
* Directories are scanned recursively.
20+
*
21+
* @return list<Path>
22+
*/
23+
public function find(Path $path) : array
24+
{
25+
$status = $this->filesystem->status($path);
26+
27+
if ($status !== null && $status->isDirectory()) {
28+
$path = path(\rtrim($path->path(), '/') . '/**/*.sql', $path->options());
29+
}
30+
31+
$files = [];
32+
33+
foreach ($this->filesystem->list($path) as $file) {
34+
if (!$file->isFile()) {
35+
continue;
36+
}
37+
38+
if ($file->path->extension() !== 'sql') {
39+
continue;
40+
}
41+
42+
$files[] = $file->path;
43+
}
44+
45+
return $files;
46+
}
47+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Context;
6+
7+
use function Flow\Filesystem\DSL\{native_local_filesystem, path};
8+
use Flow\Filesystem\Local\NativeLocalFilesystem;
9+
use Flow\Filesystem\Path;
10+
11+
final readonly class FilesystemContext
12+
{
13+
private NativeLocalFilesystem $filesystem;
14+
15+
private Path $workDir;
16+
17+
public function __construct(string $prefix = 'flow_test_')
18+
{
19+
$this->filesystem = native_local_filesystem();
20+
$this->workDir = path($this->filesystem->getSystemTmpDir()->path() . '/' . $prefix . \bin2hex(\random_bytes(4)));
21+
$this->filesystem->writeTo(path($this->workDir->path() . '/.keep'))->append('')->close();
22+
}
23+
24+
public function cleanup() : void
25+
{
26+
$this->filesystem->rm($this->workDir);
27+
}
28+
29+
public function filesystem() : NativeLocalFilesystem
30+
{
31+
return $this->filesystem;
32+
}
33+
34+
public function path(string $relative = '') : Path
35+
{
36+
if ($relative === '') {
37+
return $this->workDir;
38+
}
39+
40+
return path($this->workDir->path() . '/' . \ltrim($relative, '/'));
41+
}
42+
43+
public function readFile(string $relative) : string
44+
{
45+
return $this->filesystem->readFrom($this->path($relative))->content();
46+
}
47+
48+
public function writeFile(string $relative, string $content) : Path
49+
{
50+
$path = $this->path($relative);
51+
$this->filesystem->writeTo($path)->append($content)->close();
52+
53+
return $path;
54+
}
55+
}

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Command/CommandTestCase.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@
44

55
namespace Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Integration\Command;
66

7+
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Context\FilesystemContext;
78
use PHPUnit\Framework\TestCase;
89

910
abstract class CommandTestCase extends TestCase
1011
{
1112
protected CommandTestContext $context;
1213

14+
protected FilesystemContext $fs;
15+
1316
protected function setUp() : void
1417
{
1518
$this->context = new CommandTestContext();
1619
$this->context->bootWithMigrations();
20+
$this->fs = new FilesystemContext();
1721
}
1822

1923
protected function tearDown() : void
2024
{
25+
$this->fs->cleanup();
2126
$this->context->shutdown();
2227
}
2328
}

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Command/CommandTestContext.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ public function bootWithMigrationsForDatabase(string $database) : void
114114
]);
115115
}
116116

117+
public function command(string $serviceId) : Command
118+
{
119+
/** @var Command $command */
120+
$command = $this->container()->get($serviceId);
121+
122+
return $command;
123+
}
124+
117125
public function container() : ContainerInterface
118126
{
119127
return $this->symfonyContext->getContainer();

0 commit comments

Comments
 (0)