Skip to content

Commit 2d31427

Browse files
committed
feature: flow:sql:format CLI command
1 parent 405e486 commit 2d31427

7 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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_format;
9+
use function Flow\Types\DSL\type_string;
10+
use Flow\Filesystem\Local\NativeLocalFilesystem;
11+
use Flow\Filesystem\Path as FsPath;
12+
use Symfony\Component\Console\Attribute\AsCommand;
13+
use Symfony\Component\Console\Command\Command;
14+
use Symfony\Component\Console\Input\{InputArgument, InputInterface, InputOption};
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
#[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.')]
18+
final class FormatSqlCommand extends Command
19+
{
20+
private readonly NativeLocalFilesystem $filesystem;
21+
22+
public function __construct()
23+
{
24+
parent::__construct();
25+
$this->filesystem = native_local_filesystem();
26+
}
27+
28+
protected function configure() : void
29+
{
30+
$this
31+
->addArgument('sql', InputArgument::OPTIONAL, 'Raw SQL string to format. Ignored when --path is used.')
32+
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Path, directory or glob pattern (only files with .sql extension are processed)')
33+
->addOption('write', 'w', InputOption::VALUE_NONE, 'Write formatted SQL back to source files instead of printing to stdout')
34+
->addOption('check', null, InputOption::VALUE_NONE, 'Exit with non-zero status when any input is not already formatted (does not modify files)');
35+
}
36+
37+
protected function execute(InputInterface $input, OutputInterface $output) : int
38+
{
39+
$pathOption = $input->getOption('path');
40+
$sqlArgument = $input->getArgument('sql');
41+
$write = (bool) $input->getOption('write');
42+
$check = (bool) $input->getOption('check');
43+
44+
if ($pathOption === null && $sqlArgument === null) {
45+
$output->writeln('<error>Either a SQL string argument or --path option must be provided.</error>');
46+
47+
return Command::FAILURE;
48+
}
49+
50+
if ($pathOption !== null) {
51+
return $this->formatPath(type_string()->assert($pathOption), $write, $check, $output);
52+
}
53+
54+
$sql = type_string()->assert($sqlArgument);
55+
$formatted = sql_format($sql);
56+
57+
if ($check) {
58+
if (\trim($formatted) !== \trim($sql)) {
59+
$output->writeln('<error>SQL is not formatted.</error>');
60+
61+
return Command::FAILURE;
62+
}
63+
64+
$output->writeln('<info>SQL is already formatted.</info>');
65+
66+
return Command::SUCCESS;
67+
}
68+
69+
$output->writeln($formatted);
70+
71+
return Command::SUCCESS;
72+
}
73+
74+
/**
75+
* @return list<FsPath>
76+
*/
77+
private function collectSqlFiles(FsPath $path) : array
78+
{
79+
$status = $this->filesystem->status($path);
80+
81+
if ($status !== null && $status->isDirectory()) {
82+
$path = path(\rtrim($path->path(), '/') . '/**/*.sql', $path->options());
83+
}
84+
85+
$files = [];
86+
87+
foreach ($this->filesystem->list($path) as $file) {
88+
if (!$file->isFile()) {
89+
continue;
90+
}
91+
92+
if ($file->path->extension() !== 'sql') {
93+
continue;
94+
}
95+
96+
$files[] = $file->path;
97+
}
98+
99+
return $files;
100+
}
101+
102+
private function formatPath(string $pathString, bool $write, bool $check, OutputInterface $output) : int
103+
{
104+
$files = $this->collectSqlFiles(path($pathString));
105+
106+
if ($files === []) {
107+
$output->writeln(\sprintf('<comment>No .sql files found at: %s</comment>', $pathString));
108+
109+
return Command::SUCCESS;
110+
}
111+
112+
$unformatted = 0;
113+
$formattedCount = 0;
114+
115+
foreach ($files as $file) {
116+
$original = $this->filesystem->readFrom($file)->content();
117+
118+
try {
119+
$formatted = sql_format($original);
120+
} catch (\Throwable $e) {
121+
$output->writeln(\sprintf('<error>Failed to format %s: %s</error>', $file->path(), $e->getMessage()));
122+
123+
return Command::FAILURE;
124+
}
125+
126+
$isDifferent = \trim($formatted) !== \trim($original);
127+
128+
if ($check) {
129+
if ($isDifferent) {
130+
$unformatted++;
131+
$output->writeln(\sprintf('<error>Not formatted: %s</error>', $file->path()));
132+
}
133+
134+
continue;
135+
}
136+
137+
if ($write) {
138+
if ($isDifferent) {
139+
$this->filesystem->writeTo($file)->append($formatted)->close();
140+
$formattedCount++;
141+
$output->writeln(\sprintf('<info>Formatted: %s</info>', $file->path()));
142+
}
143+
144+
continue;
145+
}
146+
147+
$output->writeln(\sprintf('<comment>-- %s</comment>', $file->path()));
148+
$output->writeln($formatted);
149+
$output->writeln('');
150+
}
151+
152+
if ($check) {
153+
if ($unformatted > 0) {
154+
$output->writeln(\sprintf('<error>%d file(s) not formatted.</error>', $unformatted));
155+
156+
return Command::FAILURE;
157+
}
158+
159+
$output->writeln(\sprintf('<info>All %d file(s) are properly formatted.</info>', \count($files)));
160+
161+
return Command::SUCCESS;
162+
}
163+
164+
if ($write) {
165+
$output->writeln(\sprintf('<info>%d file(s) reformatted, %d unchanged.</info>', $formattedCount, \count($files) - $formattedCount));
166+
}
167+
168+
return Command::SUCCESS;
169+
}
170+
}

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: 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)