Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

290 lines
7.6KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace Core;
  4. class MigrationManager
  5. {
  6. protected Database $database;
  7. protected string $path;
  8. public function __construct(Database $database, string $path)
  9. {
  10. $this->database = $database;
  11. $this->path = rtrim($path, '/');
  12. }
  13. public function ensureTable(): void
  14. {
  15. $this->database->execute(
  16. 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)'
  17. );
  18. $this->database->execute(
  19. 'DELETE FROM migrations
  20. WHERE id NOT IN (
  21. SELECT MIN(id)
  22. FROM migrations
  23. GROUP BY migration
  24. )'
  25. );
  26. $this->database->execute(
  27. 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)'
  28. );
  29. $files = array_map('basename', $this->migrationFiles());
  30. if ($files === []) {
  31. $this->database->execute('DELETE FROM migrations');
  32. return;
  33. }
  34. $placeholders = [];
  35. $parameters = [];
  36. foreach ($files as $index => $file) {
  37. $placeholder = 'migration_' . $index;
  38. $placeholders[] = ':' . $placeholder;
  39. $parameters[$placeholder] = $file;
  40. }
  41. $this->database->execute(
  42. 'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')',
  43. $parameters
  44. );
  45. }
  46. public function status(): array
  47. {
  48. $this->ensureTable();
  49. $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC');
  50. $ranByName = [];
  51. foreach ($ran as $row) {
  52. $ranByName[$row['migration']] = $row['ran_at'];
  53. }
  54. $files = $this->migrationFiles();
  55. $status = [];
  56. foreach ($files as $file) {
  57. $name = basename($file);
  58. $status[] = [
  59. 'migration' => $name,
  60. 'ran' => array_key_exists($name, $ranByName),
  61. 'ran_at' => $ranByName[$name] ?? null,
  62. ];
  63. }
  64. return $status;
  65. }
  66. public function runPending(): array
  67. {
  68. $this->ensureTable();
  69. $ran = $this->database->query('SELECT migration FROM migrations');
  70. $ranNames = array_column($ran, 'migration');
  71. $files = $this->migrationFiles();
  72. $ranMigrations = [];
  73. foreach ($files as $file) {
  74. $name = basename($file);
  75. if (in_array($name, $ranNames, true)) {
  76. continue;
  77. }
  78. $migration = $this->loadMigration($file);
  79. $this->database->pdo()->beginTransaction();
  80. try {
  81. $migration->up($this->database);
  82. $this->database->execute(
  83. 'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)',
  84. ['migration' => $name]
  85. );
  86. $this->database->pdo()->commit();
  87. $ranMigrations[] = $name;
  88. } catch (\Throwable $exception) {
  89. $this->database->pdo()->rollBack();
  90. throw $exception;
  91. }
  92. }
  93. return $ranMigrations;
  94. }
  95. public function rollback(int $steps = 1): array
  96. {
  97. $this->ensureTable();
  98. $steps = max(1, $steps);
  99. $applied = $this->database->query(
  100. "SELECT MAX(id) AS id, migration
  101. FROM migrations
  102. GROUP BY migration
  103. ORDER BY id DESC
  104. LIMIT {$steps}"
  105. );
  106. $rolledBack = [];
  107. foreach ($applied as $row) {
  108. $file = $this->path . '/' . $row['migration'];
  109. if (!file_exists($file)) {
  110. throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}");
  111. }
  112. $migration = $this->loadMigration($file);
  113. $this->database->pdo()->beginTransaction();
  114. try {
  115. $migration->down($this->database);
  116. $this->database->execute(
  117. 'DELETE FROM migrations WHERE id = :id',
  118. ['id' => $row['id']]
  119. );
  120. $this->database->pdo()->commit();
  121. $rolledBack[] = $row['migration'];
  122. } catch (\Throwable $exception) {
  123. $this->database->pdo()->rollBack();
  124. throw $exception;
  125. }
  126. }
  127. return $rolledBack;
  128. }
  129. public function fresh(): array
  130. {
  131. $this->ensureTable();
  132. $files = array_reverse($this->migrationFiles());
  133. $rolledBack = [];
  134. foreach ($files as $file) {
  135. $migration = $this->loadMigration($file);
  136. $name = basename($file);
  137. $this->database->pdo()->beginTransaction();
  138. try {
  139. $migration->down($this->database);
  140. $this->database->pdo()->commit();
  141. $rolledBack[] = $name;
  142. } catch (\Throwable $exception) {
  143. $this->database->pdo()->rollBack();
  144. throw $exception;
  145. }
  146. }
  147. $this->database->execute('DELETE FROM migrations');
  148. $ran = $this->runPending();
  149. return [
  150. 'rolled_back' => $rolledBack,
  151. 'migrated' => $ran,
  152. ];
  153. }
  154. public function make(string $name): string
  155. {
  156. $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_');
  157. if ($slug === '') {
  158. throw new \InvalidArgumentException('Migration name must contain letters or numbers.');
  159. }
  160. if (!is_dir($this->path)) {
  161. mkdir($this->path, 0777, true);
  162. }
  163. $timestamp = date('Ymd_His');
  164. $filename = $timestamp . '_' . $slug . '.php';
  165. $path = $this->path . '/' . $filename;
  166. if (file_exists($path)) {
  167. throw new \RuntimeException("Migration already exists: {$filename}");
  168. }
  169. $template = <<<PHP
  170. <?php
  171. declare(strict_types=1);
  172. use Core\Database;
  173. use Core\Migration;
  174. return new class extends Migration
  175. {
  176. public function up(Database \$database): void
  177. {
  178. // Write the forward migration here.
  179. }
  180. public function down(Database \$database): void
  181. {
  182. // Write the rollback migration here.
  183. }
  184. };
  185. PHP;
  186. file_put_contents($path, $template . PHP_EOL);
  187. return $path;
  188. }
  189. private function migrationFiles(): array
  190. {
  191. $files = glob($this->path . '/*.php') ?: [];
  192. sort($files);
  193. return $files;
  194. }
  195. private function loadMigration(string $file): Migration
  196. {
  197. $migration = require $file;
  198. if ($migration instanceof Migration) {
  199. return $migration;
  200. }
  201. if (is_callable($migration)) {
  202. return new class ($migration, basename($file)) extends Migration
  203. {
  204. private $callback;
  205. private string $name;
  206. public function __construct(callable $callback, string $name)
  207. {
  208. $this->callback = $callback;
  209. $this->name = $name;
  210. }
  211. public function up(Database $database): void
  212. {
  213. ($this->callback)($database);
  214. }
  215. public function down(Database $database): void
  216. {
  217. throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method.");
  218. }
  219. };
  220. }
  221. throw new \RuntimeException('Migration files must return a Migration instance.');
  222. }
  223. }

Powered by TurnKey Linux.