Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.47% covered (success)
89.47%
119 / 133
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MigrationExecutor
89.47% covered (success)
89.47%
119 / 133
54.55% covered (warning)
54.55%
6 / 11
41.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDryRun
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isDryRun
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeMigration
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
11.42
 executeMigrations
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 collectOperations
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getResults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearResults
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateDryRunReport
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
3.00
 formatDryRunReport
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
15
 truncateQuery
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3/**
4 * Executes migrations with tracking and dry-run support.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-25
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Setup\Migration;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Filesystem\Filesystem;
24use phpMyFAQ\Setup\Migration\Operations\OperationRecorder;
25use Throwable;
26
27class MigrationExecutor
28{
29    private bool $dryRun = false;
30
31    /** @var MigrationResult[] */
32    private array $results = [];
33
34    public function __construct(
35        private readonly Configuration $configuration,
36        private readonly MigrationTracker $tracker,
37        private readonly ?Filesystem $filesystem = null,
38    ) {
39    }
40
41    /**
42     * Sets dry-run mode.
43     */
44    public function setDryRun(bool $dryRun): self
45    {
46        $this->dryRun = $dryRun;
47        return $this;
48    }
49
50    /**
51     * Returns whether dry-run mode is enabled.
52     */
53    public function isDryRun(): bool
54    {
55        return $this->dryRun;
56    }
57
58    /**
59     * Executes a single migration.
60     */
61    public function executeMigration(MigrationInterface $migration): MigrationResult
62    {
63        $result = new MigrationResult($migration->getVersion(), $migration->getDescription());
64        $result->setDryRun($this->dryRun);
65
66        $startTime = microtime(true);
67
68        try {
69            // Record all operations
70            $recorder = new OperationRecorder($this->configuration, $this->filesystem);
71            $migration->up($recorder);
72
73            // Execute or simulate operations
74            foreach ($recorder->getOperations() as $operation) {
75                if ($this->dryRun) {
76                    $result->addOperationResult($operation, true);
77                    continue;
78                }
79
80                $success = $operation->execute();
81                $error = $success ? null : 'Operation failed';
82                $result->addOperationResult($operation, $success, $error);
83
84                if (!$success) {
85                    $result->setErrorMessage("Failed at operation: {$operation->getDescription()}");
86                    break;
87                }
88            }
89
90            // Track the migration if successful and not in dry-run mode
91            if ($result->isSuccess() && !$this->dryRun) {
92                $executionTimeMs = (int) ((microtime(true) - $startTime) * 1000);
93                $this->tracker->recordMigration(
94                    $migration->getVersion(),
95                    $executionTimeMs,
96                    $migration instanceof AbstractMigration ? $migration->getChecksum() : null,
97                    $migration->getDescription(),
98                );
99            }
100        } catch (Throwable $e) {
101            $result->setSuccess(false);
102            $result->setErrorMessage($e->getMessage());
103        }
104
105        $result->setExecutionTimeMs((microtime(true) - $startTime) * 1000);
106        $this->results[] = $result;
107
108        return $result;
109    }
110
111    /**
112     * Executes multiple migrations in order.
113     *
114     * @param MigrationInterface[] $migrations
115     * @return MigrationResult[]
116     */
117    public function executeMigrations(array $migrations): array
118    {
119        $results = [];
120
121        foreach ($migrations as $migration) {
122            $result = $this->executeMigration($migration);
123            $results[] = $result;
124
125            // Stop on failure unless in dry-run mode
126            if (!$result->isSuccess() && !$this->dryRun) {
127                break;
128            }
129        }
130
131        return $results;
132    }
133
134    /**
135     * Collects operations from migrations without executing them.
136     *
137     * @param array<string, MigrationInterface> $migrations
138     * @return array<string, array{migration: MigrationInterface, operations: array<int, array<string, mixed>>}>
139     */
140    public function collectOperations(array $migrations): array
141    {
142        $collected = [];
143
144        foreach ($migrations as $version => $migration) {
145            $recorder = new OperationRecorder($this->configuration, $this->filesystem);
146            $migration->up($recorder);
147
148            $collected[$version] = [
149                'migration' => $migration,
150                'operations' => $recorder->toArray(),
151            ];
152        }
153
154        return $collected;
155    }
156
157    /**
158     * Returns all execution results.
159     *
160     * @return MigrationResult[]
161     */
162    public function getResults(): array
163    {
164        return $this->results;
165    }
166
167    /**
168     * Clears stored results.
169     */
170    public function clearResults(): self
171    {
172        $this->results = [];
173        return $this;
174    }
175
176    /**
177     * Generates a dry-run report for the given migrations.
178     *
179     * @param array<string, MigrationInterface> $migrations
180     * @return array{
181     *     migrations: array<array-key, array<string, mixed>>,
182     *     summary: array{migrationCount: int, totalOperations: int, operationsByType: array<string, int>}
183     * }
184     */
185    public function generateDryRunReport(array $migrations): array
186    {
187        $report = [
188            'migrations' => [],
189            'summary' => [
190                'migrationCount' => 0,
191                'totalOperations' => 0,
192                'operationsByType' => [],
193            ],
194        ];
195
196        foreach ($migrations as $version => $migration) {
197            $recorder = new OperationRecorder($this->configuration, $this->filesystem);
198            $migration->up($recorder);
199
200            $operations = $recorder->toArray();
201            $counts = $recorder->getOperationCounts();
202
203            $report['migrations'][$version] = [
204                'description' => $migration->getDescription(),
205                'operationCount' => count($operations),
206                'operationsByType' => $counts,
207                'operations' => $operations,
208            ];
209
210            $report['summary']['migrationCount']++;
211            $report['summary']['totalOperations'] += count($operations);
212
213            foreach ($counts as $type => $count) {
214                $report['summary']['operationsByType'][$type] =
215                    ($report['summary']['operationsByType'][$type] ?? 0) + $count;
216            }
217        }
218
219        return $report;
220    }
221
222    /**
223     * Formats the dry-run report as a human-readable string.
224     *
225     * @param array{
226     *     migrations: array<array-key, array<string, mixed>>,
227     *     summary: array{migrationCount: int, totalOperations: int, operationsByType: array<string, int>}
228     * } $report
229     */
230    public function formatDryRunReport(array $report): string
231    {
232        $output = "=== Migration Dry-Run Report ===\n\n";
233
234        foreach ($report['migrations'] as $version => $migrationData) {
235            $description = (string) ($migrationData['description'] ?? '');
236            $output .= "Version: {$version}\n";
237            $output .= "Description: {$description}\n\n";
238
239            // Group operations by type
240            $byType = [];
241            $operations = $migrationData['operations'] ?? [];
242            foreach (is_array($operations) ? $operations : [] as $op) {
243                if (!is_array($op)) {
244                    continue;
245                }
246
247                $byType[(string) ($op['type'] ?? '')][] = $op;
248            }
249
250            // SQL Operations
251            if (($byType['sql'] ?? []) !== []) {
252                $output .= '--- SQL Operations (' . count($byType['sql']) . ") ---\n";
253                foreach ($byType['sql'] as $i => $op) {
254                    $opDescription = (string) ($op['description'] ?? '');
255                    $output .= ($i + 1) . "{$opDescription}\n";
256                    $output .= '   ' . $this->truncateQuery((string) ($op['query'] ?? '')) . "\n";
257                }
258                $output .= "\n";
259            }
260
261            // Config Operations
262            $configOps = array_merge(
263                $byType['config_add'] ?? [],
264                $byType['config_delete'] ?? [],
265                $byType['config_rename'] ?? [],
266                $byType['config_update'] ?? [],
267            );
268            if ($configOps !== []) {
269                $output .= '--- Configuration Changes (' . count($configOps) . ") ---\n";
270                foreach ($configOps as $i => $op) {
271                    $opDescription = (string) ($op['description'] ?? '');
272                    $output .= ($i + 1) . "{$opDescription}\n";
273                }
274                $output .= "\n";
275            }
276
277            // File Operations
278            $fileOps = array_merge($byType['file_copy'] ?? [], $byType['directory_copy'] ?? []);
279            if ($fileOps !== []) {
280                $output .= '--- File Operations (' . count($fileOps) . ") ---\n";
281                foreach ($fileOps as $i => $op) {
282                    $opDescription = (string) ($op['description'] ?? '');
283                    $output .= ($i + 1) . "{$opDescription}\n";
284                }
285                $output .= "\n";
286            }
287
288            // Permission Operations
289            if (($byType['permission_grant'] ?? []) !== []) {
290                $output .= '--- Permission Changes (' . count($byType['permission_grant']) . ") ---\n";
291                foreach ($byType['permission_grant'] as $i => $op) {
292                    $opDescription = (string) ($op['description'] ?? '');
293                    $output .= ($i + 1) . "{$opDescription}\n";
294                }
295                $output .= "\n";
296            }
297
298            $output .= str_repeat(string: '-', times: 50) . "\n\n";
299        }
300
301        // Summary
302        $output .= "=== Summary ===\n";
303        $output .= "Migrations: {$report['summary']['migrationCount']}\n";
304        $output .= "Total Operations: {$report['summary']['totalOperations']}\n";
305
306        if (($report['summary']['operationsByType'] ?? []) !== []) {
307            $output .= "By Type:\n";
308            foreach ($report['summary']['operationsByType'] as $type => $count) {
309                $output .= "  - {$type}{$count}\n";
310            }
311        }
312
313        return $output;
314    }
315
316    private function truncateQuery(string $query, int $maxLength = 100): string
317    {
318        $sanitized = preg_replace(pattern: '/\s+/', replacement: ' ', subject: trim($query)) ?? '';
319        if (strlen($sanitized) > $maxLength) {
320            return substr(string: $sanitized, offset: 0, length: $maxLength - 3) . '...';
321        }
322        return $sanitized;
323    }
324}