Lines
85.96%
147 / 171
Methods
75.00%
9 / 12
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| useInputStream | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| run | 82.25% | 102 / 124 | 0.00% | 0 / 1 | 31.07 | ||
| planChanges | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| declareRenameSuggestions | 96.42% | 27 / 28 | 0.00% | 0 / 1 | 13 | ||
| isInteractive | 66.66% | 2 / 3 | 0.00% | 0 / 1 | 3.33 | ||
| canPrompt | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 3 | ||
| prompt | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 1 | ||
| readAnswer | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| line | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| success | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| warn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| error | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| 63 | final class SyncCommand | |
| 64 | { | |
| 65 | public static string $command = "sync"; | |
| 66 | ||
| 67 | /** | |
| 68 | * The stream prompts read answers from. Injectable so tests can feed | |
| 69 | * scripted answers and actually exercise the prompt flows; real runs | |
| 70 | * use STDIN. | |
| 71 | */ | |
| 72 | private static mixed $inputStream = null; | |
| 73 | ||
| 74 | /** | |
| 75 | * Replace the stream prompts read from (e.g. a memory stream in tests). | |
| 76 | * Pass null to restore STDIN. | |
| 77 | */ | |
| 78 | public static function useInputStream(mixed $stream): void | |
| 79 | { | |
| 80 | self::$inputStream = $stream; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Run the sync. | |
| 85 | * | |
| 86 | * @param array<string, mixed> $options CLI options (--filter, | |
| 87 | * --exclude-filter, --dir, --force, --dry-run, --no-transactional, | |
| 88 | * --no-drop-tables) | |
| 89 | * @return string Always '' — output is echoed progressively | |
| 90 | */ | |
| 91 | public function run(array $options = []): string | |
| 92 | { | |
| 93 | // ---- Discover models ---- | |
| 94 | $dirs = isset($options['dir']) | |
| 95 | ? array_map(trim(...), explode(',', (string) $options['dir'])) | |
| 96 | : null; | |
| 97 | ||
| 98 | $models = (new ModelDiscovery())->discover($dirs); | |
| 99 | ||
| 100 | // ---- Apply filters (exclude wins) ---- | |
| 101 | try { | |
| 102 | $filter = new ModelFilter( | |
| 103 | isset($options['filter']) ? (string) $options['filter'] : null, | |
| 104 | isset($options['exclude-filter']) ? (string) $options['exclude-filter'] : null, | |
| 105 | ); | |
| 106 | } catch (\InvalidArgumentException $e) { | |
| 107 | self::error(ExceptionChain::render($e, '')); | |
| 108 | return ''; | |
| 109 | } | |
| 110 | ||
| 111 | ['active' => $active, 'excluded' => $excluded] = $filter->apply($models); | |
| 112 | ||
| 113 | if ($active === []) { | |
| 114 | self::warn("No models matched."); | |
| 115 | return ''; | |
| 116 | } | |
| 117 | ||
| 118 | // ---- Build the desired state ---- | |
| 119 | $desired = []; | |
| 120 | ||
| 121 | foreach ($active as $model) { | |
| 122 | $desired[] = Blueprint::fromMetadata($model); | |
| 123 | } | |
| 124 | ||
| 125 | // ---- Protected list: excluded models' tables (best-effort) ---- | |
| 126 | $protected = array_values(MetadataFactory::tables($excluded, skipBroken: true)); | |
| 127 | ||
| 128 | // ---- Transactional apply: default-on when the dialect supports it ---- | |
| 129 | $connection = Database::sqlConnection(); | |
| 130 | $transactional = !isset($options['no-transactional']) | |
| 131 | && $connection->supportsTransactionalDdl(); | |
| 132 | ||
| 133 | if (!isset($options['no-transactional']) && !$transactional) { | |
| 134 | self::warn( | |
| 135 | 'This dialect does not support transactional DDL — the apply will not be atomic.' | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 139 | $dryRun = isset($options['dry-run']); | |
| 140 | $force = (bool) ($options['force'] ?? false); | |
| 141 | $dropTables = !isset($options['no-drop-tables']); | |
| 142 | $synchronizer = new SchemaSynchronizer($connection); | |
| 143 | ||
| 144 | // The destructive-change gate — shared by the apply under the lock and | |
| 145 | // by a deferred apply outside it. | |
| 146 | $confirm = function (SchemaChange $change) use ($force): bool { | |
| 147 | if ($force) { | |
| 148 | return true; | |
| 149 | } | |
| 150 | ||
| 151 | self::prompt($change->description); | |
| 152 | ||
| 153 | return self::readAnswer(); | |
| 154 | }; | |
| 155 | ||
| 156 | // One progress bar spans both apply legs: under the lock, and — when | |
| 157 | // the plan carries a change needing a transaction-free connection — | |
| 158 | // after it. Created once the plan is known, assigned through the | |
| 159 | // reference so the deferred leg can finish it. | |
| 160 | $progress = null; | |
| 161 | $onChange = function (SchemaChange $change) use (&$progress): void { | |
| 162 | if ($progress !== null) { | |
| 163 | $progress->advance(); | |
| 164 | } | |
| 165 | }; | |
| 166 | ||
| 167 | /** @var list<SchemaChange>|null $deferred The plan must apply outside the lock transaction. */ | |
| 168 | $deferred = null; | |
| 169 | $applied = []; | |
| 170 | $total = 0; | |
| 171 | ||
| 172 | try { | |
| 173 | // ---- Plan → declare renames → re-plan → display → apply, UNDER the lock ---- | |
| 174 | // The whole flow stays inside the lock transaction whenever the | |
| 175 | // dialect allows. The plan-aware predicate | |
| 176 | // (changeRequiresStandaloneTransaction) resolves each change's | |
| 177 | // FK involvement through the plan itself — an alter in a | |
| 178 | // rename-led plan reads the rename source's live state — so the | |
| 179 | // check is answerable BEFORE anything applies and the plan can | |
| 180 | // be judged as a unit. When any change needs a transaction-free | |
| 181 | // connection (an FK-involved SQLite table rebuild must toggle | |
| 182 | // PRAGMA foreign_keys outside any transaction), the whole plan | |
| 183 | // is handed back and applied after the lock releases — the same | |
| 184 | // defer SchemaSynchronizer::sync() performs for one-shot | |
| 185 | // callers. Otherwise it applies here, in the same transaction, | |
| 186 | // in the differ's executable order (renames already precede the | |
| 187 | // alters that target the renamed tables). | |
| 188 | $connection->withLock(function () use ( | |
| 189 | $connection, | |
| 190 | $synchronizer, | |
| 191 | $desired, | |
| 192 | $protected, | |
| 193 | $force, | |
| 194 | $dryRun, | |
| 195 | $dropTables, | |
| 196 | $transactional, | |
| 197 | $confirm, | |
| 198 | $onChange, | |
| 199 | &$progress, | |
| 200 | &$deferred, | |
| 201 | &$applied, | |
| 202 | &$total, | |
| 203 | ): void { | |
| 204 | // Fresh copies — declaring a rename mutates a blueprint | |
| 205 | // (renamedFrom), and the originals must stay clean. | |
| 206 | $pristine = array_map(fn(Blueprint $b): Blueprint => clone $b, $desired); | |
| 207 | ||
| 208 | // ---- Plan → declare renames → re-plan (NO database changes) ---- | |
| 209 | // The loop only converges the PLAN: each flagged create/drop | |
| 210 | // pair is resolved by declaring the rename on the blueprint, | |
| 211 | // then re-planning so the differ verifies the declaration and | |
| 212 | // emits the real RenameTable change — together with any | |
| 213 | // column shape drift, in one plan. Bounded by the plan size | |
| 214 | // — each declaration converts one pair. | |
| 215 | $changes = self::planChanges($synchronizer, $pristine, $protected, $dropTables); | |
| 216 | ||
| 217 | if ($changes === []) { | |
| 218 | self::success("Schema is in sync. Nothing to do."); | |
| 219 | return; | |
| 220 | } | |
| 221 | ||
| 222 | for ($i = 0, $max = count($changes); $i < $max; $i++) { | |
| 223 | if (!self::declareRenameSuggestions($changes, $pristine, $force)) { | |
| 224 | break; // no suggestions left — the plan is final | |
| 225 | } | |
| 226 | ||
| 227 | $changes = self::planChanges($synchronizer, $pristine, $protected, $dropTables); | |
| 228 | ||
| 229 | if ($changes === []) { | |
| 230 | self::success("Schema is in sync. Nothing to do."); | |
| 231 | return; | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | if ($changes === []) { | |
| 236 | self::success("Schema is in sync. Nothing to do."); | |
| 237 | return; | |
| 238 | } | |
| 239 | ||
| 240 | // ---- The blueprints are complete — display the final plan ---- | |
| 241 | self::line(ConsoleColors::FG_CYAN . "Planned schema changes:" . ConsoleColors::RESET); | |
| 242 | foreach ($changes as $change) { | |
| 243 | $marker = $change->destructive | |
| 244 | ? ConsoleColors::FG_RED . " ! " . ConsoleColors::RESET | |
| 245 | : " "; | |
| 246 | self::line($marker . $change->description); | |
| 247 | } | |
| 248 | ||
| 249 | if ($dryRun) { | |
| 250 | self::line(ConsoleColors::FG_CYAN . "Dry run — no changes applied." . ConsoleColors::RESET); | |
| 251 | return; | |
| 252 | } | |
| 253 | ||
| 254 | // ---- Confirm gate ---- | |
| 255 | $destructive = array_filter($changes, fn(SchemaChange $c): bool => $c->destructive); | |
| 256 | ||
| 257 | // Interactive when a TTY is present OR an input stream is | |
| 258 | // injected (tests feed scripted answers through the seam — | |
| 259 | // they must reach the prompt flows, not the fail-fast gate). | |
| 260 | if ($destructive !== [] && !$force && !self::canPrompt()) { | |
| 261 | self::error( | |
| 262 | "Destructive changes present and no TTY available — re-run with --force to apply." | |
| 263 | ); | |
| 264 | return; | |
| 265 | } | |
| 266 | ||
| 267 | $total = count($changes); | |
| 268 | $progress = new ProgressBar($total); | |
| 269 | $progress->setFormat('[{bar}] {percent}% ({current}/{total})'); | |
| 270 | ||
| 271 | // A change the dialect cannot apply inside a transaction (an | |
| 272 | // FK-involved SQLite table rebuild needs the foreign_keys | |
| 273 | // PRAGMA toggle outside one) defers the whole plan past the | |
| 274 | // lock transaction — the plan is a unit, and the alters that | |
| 275 | // would follow a rebuild in the same transaction would roll | |
| 276 | // back with it anyway. The race window is fenced by the | |
| 277 | // rebuild itself: it re-reads the live table per change and | |
| 278 | // its foreign_key_check gate fails loud on drift it cannot | |
| 279 | // reconcile. | |
| 280 | if (array_any( | |
| 281 | $changes, | |
| 282 | fn (SchemaChange $c): bool => $connection->changeRequiresStandaloneTransaction($c, $changes), | |
| 283 | )) { | |
| 284 | $deferred = $changes; | |
| 285 | return; | |
| 286 | } | |
| 287 | ||
| 288 | $applied = $synchronizer->apply( | |
| 289 | $changes, | |
| 290 | confirm: $confirm, | |
| 291 | onChange: $onChange, | |
| 292 | transactional: $transactional, | |
| 293 | ); | |
| 294 | ||
| 295 | $progress->finish(); | |
| 296 | self::success(count($applied) . " of " . $total . " planned change(s) applied."); | |
| 297 | }, 'radiant:schema'); | |
| 298 | } catch (\Throwable $e) { | |
| 299 | self::error(ExceptionChain::render($e, "Sync failed: ")); | |
| 300 | return ''; | |
| 301 | } | |
| 302 | ||
| 303 | if ($deferred === null) { | |
| 304 | return ''; | |
| 305 | } | |
| 306 | ||
| 307 | // ---- Deferred apply: outside the lock transaction ---- | |
| 308 | try { | |
| 309 | $applied = $synchronizer->apply( | |
| 310 | $deferred, | |
| 311 | confirm: $confirm, | |
| 312 | onChange: $onChange, | |
| 313 | transactional: $transactional, | |
| 314 | ); | |
| 315 | ||
| 316 | $progress?->finish(); | |
| 317 | ||
| 318 | self::success(count($applied) . " of " . $total . " planned change(s) applied."); | |
| 319 | } catch (\Throwable $e) { | |
| 320 | self::error(ExceptionChain::render($e, "Sync failed: ")); | |
| 321 | } | |
| 322 | ||
| 323 | return ''; | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * Plan the changes for the given blueprints. Protected tables are | |
| 328 | * enforced by the differ itself — never dropped, never offered as a | |
| 329 | * rename target — so no post-plan filtering is needed here. With | |
| 330 | * $dropTables off the plan is additive-only: undeclared live tables | |
| 331 | * are left untouched. | |
| 332 | * | |
| 333 | * @param list<Blueprint> $blueprints | |
| 334 | * @param list<string> $protected Tables never offered for drop or rename | |
| 335 | * @param bool $dropTables Whether undeclared live tables are offered for drop | |
| 336 | * @return list<SchemaChange> | |
| 337 | */ | |
| 338 | private static function planChanges( | |
| 339 | SchemaSynchronizer $synchronizer, | |
| 340 | array $blueprints, | |
| 341 | array $protected, | |
| 342 | bool $dropTables = true, | |
| 343 | ): array { | |
| 344 | return $synchronizer->plan($blueprints, protected: $protected, dropTables: $dropTables); | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * Resolve flagged create/drop pairs by DECLARING the rename on the | |
| 349 | * blueprint — the decision, not a guess. | |
| 350 | * | |
| 351 | * When the user confirms, the desired blueprint for the new table gets | |
| 352 | * a renamedFrom declaration; the caller re-plans so the differ verifies | |
| 353 | * the declaration and emits the real RenameTable change. Each old table | |
| 354 | * can only be claimed by one rename (the differ pairs every create with | |
| 355 | * its best-overlap drop, so multiple creates can claim the same drop — | |
| 356 | * first claim wins). | |
| 357 | * | |
| 358 | * With --force every suggestion is auto-accepted. Without a TTY or an | |
| 359 | * injected input stream the flagged pair is kept as-is (the plan shows | |
| 360 | * the POSSIBLE RENAME annotation and the destructive gate handles it) | |
| 361 | * — never block on stdin we cannot read. | |
| 362 | * | |
| 363 | * @param list<SchemaChange> $changes The current plan | |
| 364 | * @param list<Blueprint> $pristine The desired blueprints (mutated in place) | |
| 365 | * @param bool $force Auto-accept every suggestion | |
| 366 | * @return bool Whether any rename was declared (caller must re-plan) | |
| 367 | */ | |
| 368 | private static function declareRenameSuggestions(array $changes, array &$pristine, bool $force): bool | |
| 369 | { | |
| 370 | // Pair up flagged creates with their flagged drops. | |
| 371 | $pairs = []; // createTable => dropTable | |
| 372 | foreach ($changes as $change) { | |
| 373 | if ($change->operation === SchemaOperation::CreateTable && $change->renameOf !== null) { | |
| 374 | $pairs[$change->table] = $change->renameOf; | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | if ($pairs === []) { | |
| 379 | return false; | |
| 380 | } | |
| 381 | ||
| 382 | // Nothing to prompt with (no TTY, no injected stream) and no | |
| 383 | // --force: keep the flagged pair as-is. | |
| 384 | if (!$force && !self::canPrompt()) { | |
| 385 | return false; | |
| 386 | } | |
| 387 | ||
| 388 | $declared = false; | |
| 389 | $claimedOldTables = []; // each old table can only be renamed once | |
| 390 | ||
| 391 | foreach ($pairs as $newTable => $oldTable) { | |
| 392 | // Only the first claim on an old table becomes a rename. | |
| 393 | if (in_array($oldTable, $claimedOldTables, true)) { | |
| 394 | continue; | |
| 395 | } | |
| 396 | ||
| 397 | // readAnswer() already normalizes to a bool — a plain falsy | |
| 398 | // check is the correct gate (a strict string compare would | |
| 399 | // treat a typed "yes" as a decline and re-prompt every | |
| 400 | // remaining pair with the same old table). | |
| 401 | if ($force) { | |
| 402 | $answer = true; | |
| 403 | } else { | |
| 404 | self::prompt( | |
| 405 | "POSSIBLE RENAME: [{$oldTable}] → [{$newTable}]. Treat as a rename?" | |
| 406 | ); | |
| 407 | $answer = self::readAnswer(); | |
| 408 | } | |
| 409 | ||
| 410 | if (!$answer) { | |
| 411 | continue; | |
| 412 | } | |
| 413 | ||
| 414 | foreach ($pristine as $index => $blueprint) { | |
| 415 | if ($blueprint->getTable() === $newTable) { | |
| 416 | $pristine[$index] = $blueprint->renamedFrom($oldTable); | |
| 417 | $claimedOldTables[] = $oldTable; | |
| 418 | $declared = true; | |
| 419 | break; | |
| 420 | } | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | return $declared; | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Whether the process has an interactive terminal on stdin. | |
| 429 | * | |
| 430 | * The LUCENT_NON_INTERACTIVE env var forces the non-interactive path — | |
| 431 | * used by the test suite and CI runners; a TTY on stdin would otherwise | |
| 432 | * make the prompts block on fgets() forever. An injected input stream | |
| 433 | * (useInputStream) also implies non-interactive TTY detection is | |
| 434 | * irrelevant — prompts are answered from the stream. | |
| 435 | */ | |
| 436 | private static function isInteractive(): bool | |
| 437 | { | |
| 438 | if (getenv('LUCENT_NON_INTERACTIVE') === '1') { | |
| 439 | return false; | |
| 440 | } | |
| 441 | ||
| 442 | return is_resource(STDIN) && stream_isatty(STDIN); | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Whether prompts can be asked AND answered: a TTY on stdin, or an | |
| 447 | * injected input stream (tests feed scripted answers through the | |
| 448 | * seam). The rename suggestions and the destructive confirm gate share | |
| 449 | * this definition so both prompt flows are reachable under the same | |
| 450 | * conditions. | |
| 451 | */ | |
| 452 | private static function canPrompt(): bool | |
| 453 | { | |
| 454 | return self::isInteractive() | |
| 455 | || (self::$inputStream !== null && is_resource(self::$inputStream)); | |
| 456 | } | |
| 457 | ||
| 458 | /** | |
| 459 | * Display a prompt question on STDERR. | |
| 460 | * | |
| 461 | * Prompts go to STDERR, never STDOUT: they are interaction, not result | |
| 462 | * output. This keeps them visible when stdout is redirected or captured | |
| 463 | * (ob_start in test/execute mode would otherwise SWALLOW the question — | |
| 464 | * the user would see a silent hang) and follows the git/composer/ssh | |
| 465 | * convention of separating interaction from results. | |
| 466 | */ | |
| 467 | private static function prompt(string $question): void | |
| 468 | { | |
| 469 | fwrite( | |
| 470 | STDERR, | |
| 471 | ConsoleColors::FG_YELLOW . $question . ConsoleColors::RESET . "\n" | |
| 472 | . "Apply? Type yes or no: " | |
| 473 | ); | |
| 474 | } | |
| 475 | ||
| 476 | /** | |
| 477 | * Read a yes/no answer from the injected stream or STDIN. | |
| 478 | */ | |
| 479 | private static function readAnswer(): bool | |
| 480 | { | |
| 481 | $stream = self::$inputStream ?? STDIN; | |
| 482 | $answer = strtolower(trim((string) fgets($stream))); | |
| 483 | ||
| 484 | return in_array($answer, ['y', 'yes'], true); | |
| 485 | } | |
| 486 | ||
| 487 | private static function line(string $message): void | |
| 488 | { | |
| 489 | echo $message . "\n"; | |
| 490 | } | |
| 491 | ||
| 492 | private static function success(string $message): void | |
| 493 | { | |
| 494 | echo ConsoleColors::FG_GREEN . $message . ConsoleColors::RESET . "\n"; | |
| 495 | } | |
| 496 | ||
| 497 | private static function warn(string $message): void | |
| 498 | { | |
| 499 | echo ConsoleColors::FG_YELLOW . $message . ConsoleColors::RESET . "\n"; | |
| 500 | } | |
| 501 | ||
| 502 | private static function error(string $message): void | |
| 503 | { | |
| 504 | echo ConsoleColors::FG_RED . $message . ConsoleColors::RESET . "\n"; | |
| 505 | } | |
| 506 | } |