Skip to content

Commit 59d32e8

Browse files
committed
feat: collect actual SQL queries in migration dry-run mode
- Add SqlQueryCollector class to collect SQL queries via event dispatcher - Update migrateUp() to execute migration in transaction and collect SQL in dry-run mode - Update migrate() to call migrateUp() for each migration in dry-run mode (not just return list) - Add testMigrationRunnerDryRunCollectsActualSql() test to verify SQL collection - Update existing tests to work with new SQL collection logic - Format SQL queries with parameter substitution for readable output - Rollback transaction after collecting SQL to prevent actual changes Now pdodb migrate up --dry-run shows actual SQL queries that would be executed, not just comments like 'Would execute migration.up()'.
1 parent 6c5280a commit 59d32e8

File tree

4 files changed

+377
-7
lines changed

4 files changed

+377
-7
lines changed

src/migrations/MigrationRunner.php

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,17 @@ public function migrate(int $limit = 0): array
213213
$newMigrations = array_slice($newMigrations, 0, $limit);
214214
}
215215

216-
// In dry-run or pretend mode, just return the list without executing
217-
if ($this->dryRun || $this->pretend) {
216+
// In dry-run mode, execute migrations in transaction to collect SQL
217+
if ($this->dryRun) {
218+
$this->clearCollectedQueries();
219+
foreach ($newMigrations as $version) {
220+
$this->migrateUp($version);
221+
}
222+
return $newMigrations;
223+
}
224+
225+
// In pretend mode, just return the list without executing
226+
if ($this->pretend) {
218227
$this->clearCollectedQueries();
219228
$batch = $this->getNextBatchNumber();
220229

@@ -413,8 +422,32 @@ public function migrateUp(string $version): void
413422
return; // Already applied
414423
}
415424

416-
// In dry-run or pretend mode, just collect info without executing
417-
if ($this->dryRun || $this->pretend) {
425+
// In dry-run mode, execute migration in transaction and collect SQL
426+
if ($this->dryRun) {
427+
$this->collectedQueries[] = "-- Migration: {$version}";
428+
$this->collectedQueries[] = '';
429+
430+
try {
431+
$this->db->startTransaction();
432+
$migration = $this->loadMigration($version);
433+
434+
// Collect SQL queries during migration execution
435+
$this->collectMigrationSql($migration, $version);
436+
437+
$this->db->rollback(); // Always rollback in dry-run mode
438+
} catch (\Throwable $e) {
439+
// Rollback if transaction is still active
440+
if ($this->db->connection->inTransaction()) {
441+
$this->db->rollback();
442+
}
443+
// Still collect the error as a comment
444+
$this->collectedQueries[] = '-- Error: ' . $e->getMessage();
445+
}
446+
return;
447+
}
448+
449+
// In pretend mode, just collect info without executing
450+
if ($this->pretend) {
418451
$batch = $this->getNextBatchNumber();
419452
$this->collectedQueries[] = "-- Migration: {$version}";
420453
$this->collectedQueries[] = '-- Would execute migration.up()';
@@ -441,6 +474,125 @@ public function migrateUp(string $version): void
441474
}
442475
}
443476

477+
/**
478+
* Collect SQL queries from migration execution.
479+
*
480+
* @param Migration $migration Migration instance
481+
* @param string $version Migration version
482+
*/
483+
protected function collectMigrationSql(Migration $migration, string $version): void
484+
{
485+
// Create SQL collector
486+
$sqlCollector = new SqlQueryCollector();
487+
488+
// Set up event dispatcher with SQL collector if available
489+
$originalDispatcher = $this->db->getEventDispatcher();
490+
$collectorDispatcher = null;
491+
$connection = $this->db->connection;
492+
$originalConnectionDispatcher = $connection->getEventDispatcher();
493+
494+
if (interface_exists(\Psr\EventDispatcher\EventDispatcherInterface::class)) {
495+
// Create a simple event dispatcher that collects SQL queries
496+
$collectorDispatcher = new class ($sqlCollector) implements \Psr\EventDispatcher\EventDispatcherInterface {
497+
public function __construct(
498+
private SqlQueryCollector $collector
499+
) {
500+
}
501+
502+
public function dispatch(object $event): object
503+
{
504+
if ($event instanceof \tommyknocker\pdodb\events\QueryExecutedEvent) {
505+
$this->collector->handleQueryExecuted($event);
506+
}
507+
return $event;
508+
}
509+
};
510+
511+
// Temporarily set the collector dispatcher
512+
$this->db->setEventDispatcher($collectorDispatcher);
513+
$connection->setEventDispatcher($collectorDispatcher);
514+
}
515+
516+
try {
517+
// Execute migration - SQL will be collected via event dispatcher
518+
$migration->up();
519+
520+
// Collect SQL from the collector
521+
$collectedSql = $sqlCollector->getQueries();
522+
if (!empty($collectedSql)) {
523+
$this->collectedQueries = array_merge($this->collectedQueries, $collectedSql);
524+
} else {
525+
// Fallback: try to get SQL from connection's lastQuery
526+
$lastQuery = $this->db->lastQuery;
527+
if ($lastQuery !== null && $lastQuery !== '') {
528+
$this->collectedQueries[] = $lastQuery;
529+
}
530+
}
531+
} catch (\Throwable $e) {
532+
// Migration failed, but we still want to show what SQL was attempted
533+
$this->collectedQueries[] = '-- Error during migration execution: ' . $e->getMessage();
534+
535+
// Still try to collect any SQL that was executed before the error
536+
$collectedSql = $sqlCollector->getQueries();
537+
if (!empty($collectedSql)) {
538+
$this->collectedQueries = array_merge($this->collectedQueries, $collectedSql);
539+
}
540+
} finally {
541+
// Restore original event dispatcher
542+
if ($collectorDispatcher !== null) {
543+
$this->db->setEventDispatcher($originalDispatcher);
544+
$connection->setEventDispatcher($originalConnectionDispatcher);
545+
}
546+
}
547+
548+
// Add migration record SQL
549+
$batch = $this->getNextBatchNumber();
550+
$schema = $this->db->schema();
551+
$dialect = $schema->getDialect();
552+
[$insertSql, $insertParams] = $dialect->buildMigrationInsertSql($this->migrationTable, $version, $batch);
553+
554+
// Format SQL with parameters
555+
$formattedSql = $this->formatSqlWithParams($insertSql, $insertParams);
556+
$this->collectedQueries[] = '';
557+
$this->collectedQueries[] = "-- Would record migration in batch {$batch}";
558+
$this->collectedQueries[] = $formattedSql;
559+
}
560+
561+
/**
562+
* Format SQL query with parameters.
563+
*
564+
* @param string $sql SQL query
565+
* @param array<int|string, mixed> $params Query parameters
566+
*
567+
* @return string Formatted SQL
568+
*/
569+
protected function formatSqlWithParams(string $sql, array $params): string
570+
{
571+
if (empty($params)) {
572+
return $sql;
573+
}
574+
575+
// Simple parameter replacement for display
576+
$formatted = $sql;
577+
foreach ($params as $key => $value) {
578+
$placeholder = is_int($key) ? '?' : ':' . $key;
579+
if (is_string($value)) {
580+
$formattedValue = "'" . addslashes($value) . "'";
581+
} elseif (is_int($value) || is_float($value)) {
582+
$formattedValue = (string)$value;
583+
} elseif (is_bool($value)) {
584+
$formattedValue = $value ? '1' : '0';
585+
} elseif ($value === null) {
586+
$formattedValue = 'NULL';
587+
} else {
588+
$formattedValue = "'" . addslashes((string)$value) . "'";
589+
}
590+
$formatted = str_replace($placeholder, $formattedValue, $formatted);
591+
}
592+
593+
return $formatted;
594+
}
595+
444596
/**
445597
* Load migration class instance.
446598
*
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace tommyknocker\pdodb\migrations;
6+
7+
use Psr\EventDispatcher\ListenerProviderInterface;
8+
use tommyknocker\pdodb\events\QueryExecutedEvent;
9+
10+
/**
11+
* SQL Query Collector for dry-run mode.
12+
*
13+
* Collects SQL queries executed during migration execution.
14+
*/
15+
class SqlQueryCollector implements ListenerProviderInterface
16+
{
17+
/** @var array<int, string> Collected SQL queries */
18+
protected array $queries = [];
19+
20+
/**
21+
* Get collected SQL queries.
22+
*
23+
* @return array<int, string>
24+
*/
25+
public function getQueries(): array
26+
{
27+
return $this->queries;
28+
}
29+
30+
/**
31+
* Clear collected queries.
32+
*/
33+
public function clear(): void
34+
{
35+
$this->queries = [];
36+
}
37+
38+
/**
39+
* Handle query executed event.
40+
*
41+
* @param QueryExecutedEvent $event
42+
*/
43+
public function handleQueryExecuted(QueryExecutedEvent $event): void
44+
{
45+
$sql = $event->getSql();
46+
$params = $event->getParams();
47+
48+
// Format SQL with parameters
49+
$formattedSql = $this->formatSqlWithParams($sql, $params);
50+
$this->queries[] = $formattedSql;
51+
}
52+
53+
/**
54+
* Format SQL query with parameters.
55+
*
56+
* @param string $sql SQL query
57+
* @param array<int|string, mixed> $params Query parameters
58+
*
59+
* @return string Formatted SQL
60+
*/
61+
protected function formatSqlWithParams(string $sql, array $params): string
62+
{
63+
if (empty($params)) {
64+
return $sql;
65+
}
66+
67+
// Simple parameter replacement for display
68+
$formatted = $sql;
69+
foreach ($params as $key => $value) {
70+
$placeholder = is_int($key) ? '?' : ':' . $key;
71+
$formattedValue = $this->formatValue($value);
72+
// Replace first occurrence of placeholder
73+
$replaced = preg_replace('/' . preg_quote($placeholder, '/') . '/', $formattedValue, $formatted, 1);
74+
if (is_string($replaced)) {
75+
$formatted = $replaced;
76+
}
77+
}
78+
79+
return $formatted;
80+
}
81+
82+
/**
83+
* Format value for SQL display.
84+
*
85+
* @param mixed $value Value to format
86+
*
87+
* @return string Formatted value
88+
*/
89+
protected function formatValue(mixed $value): string
90+
{
91+
if ($value === null) {
92+
return 'NULL';
93+
}
94+
if (is_bool($value)) {
95+
return $value ? '1' : '0';
96+
}
97+
if (is_int($value) || is_float($value)) {
98+
return (string)$value;
99+
}
100+
if (is_string($value)) {
101+
return "'" . addslashes($value) . "'";
102+
}
103+
if (is_array($value)) {
104+
try {
105+
$json = json_encode($value, JSON_THROW_ON_ERROR);
106+
return "'" . addslashes($json) . "'";
107+
} catch (\JsonException) {
108+
return "'" . addslashes('[...]') . "'";
109+
}
110+
}
111+
112+
// Convert to string safely for other types
113+
if (is_object($value) && method_exists($value, '__toString')) {
114+
return "'" . addslashes($value->__toString()) . "'";
115+
}
116+
117+
return "'" . addslashes(serialize($value)) . "'";
118+
}
119+
120+
/**
121+
* {@inheritDoc}
122+
*
123+
* @return iterable<int, callable(QueryExecutedEvent): void>
124+
*/
125+
public function getListenersForEvent(object $event): iterable
126+
{
127+
if ($event instanceof QueryExecutedEvent) {
128+
yield [$this, 'handleQueryExecuted'];
129+
}
130+
}
131+
}

tests/shared/ApplicationAndCliCommandsTests.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,14 @@ public function testMigrateCommandDryRunAndPretendViaApplication(): void
217217
throw $e;
218218
}
219219
$this->assertSame(0, $codeDry);
220-
$this->assertStringContainsString('Would execute', $outDry);
220+
// In dry-run mode, should show SQL queries or migration info
221+
$this->assertTrue(
222+
str_contains($outDry, 'CREATE TABLE') ||
223+
str_contains($outDry, 'Would execute') ||
224+
str_contains($outDry, 'Migration:') ||
225+
str_contains($outDry, 'DRY-RUN'),
226+
'Dry-run output should contain SQL queries or migration info'
227+
);
221228

222229
// migrate down --pretend (skip confirmation with --force)
223230
ob_start();

0 commit comments

Comments
 (0)