1 | <?php |
||||||||||
2 | |||||||||||
3 | declare(strict_types=1); |
||||||||||
4 | |||||||||||
5 | namespace Cycle\Migrations; |
||||||||||
6 | |||||||||||
7 | use Cycle\Database\Database; |
||||||||||
8 | use Cycle\Database\DatabaseProviderInterface; |
||||||||||
9 | use Cycle\Database\Table; |
||||||||||
10 | use Cycle\Migrations\Config\MigrationConfig; |
||||||||||
11 | use Cycle\Migrations\Exception\MigrationException; |
||||||||||
12 | |||||||||||
13 | final class Migrator |
||||||||||
14 | { |
||||||||||
15 | private const DB_DATE_FORMAT = 'Y-m-d H:i:s'; |
||||||||||
16 | |||||||||||
17 | private const MIGRATION_TABLE_FIELDS_LIST = [ |
||||||||||
18 | 'id', |
||||||||||
19 | 'migration', |
||||||||||
20 | 'time_executed', |
||||||||||
21 | 'created_at', |
||||||||||
22 | ]; |
||||||||||
23 | |||||||||||
24 | 584 | public function __construct( |
|||||||||
25 | private MigrationConfig $config, |
||||||||||
26 | private DatabaseProviderInterface $dbal, |
||||||||||
27 | private RepositoryInterface $repository |
||||||||||
28 | ) { |
||||||||||
29 | } |
||||||||||
30 | |||||||||||
31 | 8 | public function getConfig(): MigrationConfig |
|||||||||
32 | { |
||||||||||
33 | 8 | return $this->config; |
|||||||||
34 | } |
||||||||||
35 | |||||||||||
36 | 8 | public function getRepository(): RepositoryInterface |
|||||||||
37 | { |
||||||||||
38 | 8 | return $this->repository; |
|||||||||
39 | } |
||||||||||
40 | |||||||||||
41 | /** |
||||||||||
42 | * Check if all related databases are configures with migrations. |
||||||||||
43 | */ |
||||||||||
44 | 352 | public function isConfigured(): bool |
|||||||||
45 | { |
||||||||||
46 | 352 | foreach ($this->dbal->getDatabases() as $db) { |
|||||||||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||||||||
47 | 352 | if (!$db->hasTable($this->config->getTable()) || !$this->checkMigrationTableStructure($db)) { |
|||||||||
48 | 352 | return false; |
|||||||||
49 | } |
||||||||||
50 | } |
||||||||||
51 | |||||||||||
52 | 312 | return !$this->isRestoreMigrationDataRequired(); |
|||||||||
53 | } |
||||||||||
54 | |||||||||||
55 | /** |
||||||||||
56 | * Configure all related databases with migration table. |
||||||||||
57 | */ |
||||||||||
58 | 336 | public function configure(): void |
|||||||||
59 | { |
||||||||||
60 | 336 | if ($this->isConfigured()) { |
|||||||||
61 | 8 | return; |
|||||||||
62 | } |
||||||||||
63 | |||||||||||
64 | 336 | foreach ($this->dbal->getDatabases() as $db) { |
|||||||||
65 | 336 | $schema = $db->table($this->config->getTable())->getSchema(); |
|||||||||
66 | |||||||||||
67 | // Schema update will automatically sync all needed data |
||||||||||
68 | 336 | $schema->primary('id'); |
|||||||||
69 | 336 | $schema->string('migration', 191)->nullable(false); |
|||||||||
70 | 336 | $schema->datetime('time_executed')->datetime(); |
|||||||||
71 | 336 | $schema->datetime('created_at')->datetime(); |
|||||||||
72 | 336 | $schema->index(['migration', 'created_at']) |
|||||||||
73 | 336 | ->unique(true); |
|||||||||
74 | |||||||||||
75 | 336 | if ($schema->hasIndex(['migration'])) { |
|||||||||
76 | $schema->dropIndex(['migration']); |
||||||||||
77 | } |
||||||||||
78 | |||||||||||
79 | 336 | $schema->save(); |
|||||||||
80 | } |
||||||||||
81 | |||||||||||
82 | 336 | if ($this->isRestoreMigrationDataRequired()) { |
|||||||||
83 | $this->restoreMigrationData(); |
||||||||||
84 | } |
||||||||||
85 | } |
||||||||||
86 | |||||||||||
87 | /** |
||||||||||
88 | * Get every available migration with valid meta information. |
||||||||||
89 | * |
||||||||||
90 | * @return MigrationInterface[] |
||||||||||
91 | */ |
||||||||||
92 | 304 | public function getMigrations(): array |
|||||||||
93 | { |
||||||||||
94 | 304 | $result = []; |
|||||||||
95 | 304 | foreach ($this->repository->getMigrations() as $migration) { |
|||||||||
96 | //Populating migration state and execution time (if any) |
||||||||||
97 | 296 | $result[] = $migration->withState($this->resolveState($migration)); |
|||||||||
98 | } |
||||||||||
99 | |||||||||||
100 | 304 | return $result; |
|||||||||
101 | } |
||||||||||
102 | |||||||||||
103 | /** |
||||||||||
104 | * Execute one migration and return it's instance. |
||||||||||
105 | * |
||||||||||
106 | * @throws MigrationException |
||||||||||
107 | */ |
||||||||||
108 | 304 | public function run(CapsuleInterface $capsule = null): ?MigrationInterface |
|||||||||
109 | { |
||||||||||
110 | 304 | if (!$this->isConfigured()) { |
|||||||||
111 | 8 | throw new MigrationException('Unable to run migration, Migrator not configured'); |
|||||||||
112 | } |
||||||||||
113 | |||||||||||
114 | 296 | foreach ($this->getMigrations() as $migration) { |
|||||||||
115 | 296 | if ($migration->getState()->getStatus() !== State::STATUS_PENDING) { |
|||||||||
116 | 104 | continue; |
|||||||||
117 | } |
||||||||||
118 | |||||||||||
119 | try { |
||||||||||
120 | 296 | $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase())); |
|||||||||
121 | 296 | $capsule->getDatabase($migration->getDatabase())->transaction( |
|||||||||
0 ignored issues
–
show
The call to
Cycle\Migrations\Capsule::getDatabase() has too many arguments starting with $migration->getDatabase() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue. If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.
Loading history...
The call to
Cycle\Migrations\CapsuleInterface::getDatabase() has too many arguments starting with $migration->getDatabase() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue. If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.
Loading history...
|
|||||||||||
122 | 296 | static function () use ($migration, $capsule): void { |
|||||||||
123 | 296 | $migration->withCapsule($capsule)->up(); |
|||||||||
124 | } |
||||||||||
125 | ); |
||||||||||
126 | |||||||||||
127 | 144 | $this->migrationTable($migration->getDatabase())->insertOne( |
|||||||||
128 | [ |
||||||||||
129 | 144 | 'migration' => $migration->getState()->getName(), |
|||||||||
130 | 144 | 'time_executed' => new \DateTime('now'), |
|||||||||
131 | 144 | 'created_at' => $this->getMigrationCreatedAtForDb($migration), |
|||||||||
132 | ] |
||||||||||
133 | ); |
||||||||||
134 | |||||||||||
135 | 144 | return $migration->withState($this->resolveState($migration)); |
|||||||||
136 | 160 | } catch (\Throwable $exception) { |
|||||||||
137 | 160 | throw new MigrationException( |
|||||||||
138 | 160 | \sprintf( |
|||||||||
139 | 'Error in the migration (%s) occurred: %s', |
||||||||||
140 | 160 | \sprintf( |
|||||||||
141 | '%s (%s)', |
||||||||||
142 | 160 | $migration->getState()->getName(), |
|||||||||
143 | 160 | $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT) |
|||||||||
144 | ), |
||||||||||
145 | 160 | $exception->getMessage() |
|||||||||
146 | ), |
||||||||||
147 | 160 | (int)$exception->getCode(), |
|||||||||
148 | $exception |
||||||||||
149 | ); |
||||||||||
150 | } |
||||||||||
151 | } |
||||||||||
152 | |||||||||||
153 | return null; |
||||||||||
154 | } |
||||||||||
155 | |||||||||||
156 | /** |
||||||||||
157 | * Rollback last migration and return it's instance. |
||||||||||
158 | * |
||||||||||
159 | * @throws \Throwable |
||||||||||
160 | */ |
||||||||||
161 | 144 | public function rollback(CapsuleInterface $capsule = null): ?MigrationInterface |
|||||||||
162 | { |
||||||||||
163 | 144 | if (!$this->isConfigured()) { |
|||||||||
164 | 8 | throw new MigrationException('Unable to run migration, Migrator not configured'); |
|||||||||
165 | } |
||||||||||
166 | |||||||||||
167 | /** @var MigrationInterface $migration */ |
||||||||||
168 | 136 | foreach (array_reverse($this->getMigrations()) as $migration) { |
|||||||||
169 | 136 | if ($migration->getState()->getStatus() !== State::STATUS_EXECUTED) { |
|||||||||
170 | 88 | continue; |
|||||||||
171 | } |
||||||||||
172 | |||||||||||
173 | 136 | $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase())); |
|||||||||
174 | 136 | $capsule->getDatabase()->transaction( |
|||||||||
175 | 136 | static function () use ($migration, $capsule): void { |
|||||||||
176 | 136 | $migration->withCapsule($capsule)->down(); |
|||||||||
177 | } |
||||||||||
178 | ); |
||||||||||
179 | |||||||||||
180 | 136 | $migrationData = $this->fetchMigrationData($migration); |
|||||||||
181 | |||||||||||
182 | 136 | if (!empty($migrationData)) { |
|||||||||
183 | 136 | $this->migrationTable($migration->getDatabase()) |
|||||||||
184 | 136 | ->delete(['id' => $migrationData['id']]) |
|||||||||
185 | 136 | ->run(); |
|||||||||
186 | } |
||||||||||
187 | |||||||||||
188 | 136 | return $migration->withState($this->resolveState($migration)); |
|||||||||
189 | } |
||||||||||
190 | |||||||||||
191 | return null; |
||||||||||
192 | } |
||||||||||
193 | |||||||||||
194 | /** |
||||||||||
195 | * Clarify migration state with valid status and execution time |
||||||||||
196 | */ |
||||||||||
197 | 296 | protected function resolveState(MigrationInterface $migration): State |
|||||||||
198 | { |
||||||||||
199 | 296 | $db = $this->dbal->database($migration->getDatabase()); |
|||||||||
200 | |||||||||||
201 | 296 | $data = $this->fetchMigrationData($migration); |
|||||||||
202 | |||||||||||
203 | 296 | if (empty($data['time_executed'])) { |
|||||||||
204 | 296 | return $migration->getState()->withStatus(State::STATUS_PENDING); |
|||||||||
205 | } |
||||||||||
206 | |||||||||||
207 | 144 | return $migration->getState()->withStatus( |
|||||||||
208 | State::STATUS_EXECUTED, |
||||||||||
209 | 144 | new \DateTimeImmutable($data['time_executed'], $db->getDriver()->getTimezone()) |
|||||||||
210 | ); |
||||||||||
211 | } |
||||||||||
212 | |||||||||||
213 | /** |
||||||||||
214 | * Migration table, all migration information will be stored in it. |
||||||||||
215 | * |
||||||||||
216 | * @param string|null $database |
||||||||||
217 | */ |
||||||||||
218 | 296 | protected function migrationTable(string $database = null): Table |
|||||||||
219 | { |
||||||||||
220 | 296 | return $this->dbal->database($database)->table($this->config->getTable()); |
|||||||||
0 ignored issues
–
show
|
|||||||||||
221 | } |
||||||||||
222 | |||||||||||
223 | 312 | protected function checkMigrationTableStructure(Database $db): bool |
|||||||||
224 | { |
||||||||||
225 | 312 | $table = $db->table($this->config->getTable()); |
|||||||||
226 | |||||||||||
227 | 312 | foreach (self::MIGRATION_TABLE_FIELDS_LIST as $field) { |
|||||||||
228 | 312 | if (!$table->hasColumn($field)) { |
|||||||||
229 | return false; |
||||||||||
230 | } |
||||||||||
231 | } |
||||||||||
232 | |||||||||||
233 | 312 | return !(!$table->hasIndex(['migration', 'created_at'])); |
|||||||||
234 | } |
||||||||||
235 | |||||||||||
236 | /** |
||||||||||
237 | * Fetch migration information from database |
||||||||||
238 | */ |
||||||||||
239 | 296 | protected function fetchMigrationData(MigrationInterface $migration): ?array |
|||||||||
240 | { |
||||||||||
241 | 296 | $migrationData = $this->migrationTable($migration->getDatabase()) |
|||||||||
242 | 296 | ->select('id', 'time_executed', 'created_at') |
|||||||||
243 | 296 | ->where( |
|||||||||
244 | [ |
||||||||||
245 | 296 | 'migration' => $migration->getState()->getName(), |
|||||||||
246 | 296 | 'created_at' => $this->getMigrationCreatedAtForDb($migration)->format(self::DB_DATE_FORMAT), |
|||||||||
247 | ] |
||||||||||
248 | ) |
||||||||||
249 | 296 | ->run() |
|||||||||
250 | 296 | ->fetch(); |
|||||||||
251 | |||||||||||
252 | 296 | return is_array($migrationData) ? $migrationData : []; |
|||||||||
253 | } |
||||||||||
254 | |||||||||||
255 | protected function restoreMigrationData(): void |
||||||||||
256 | { |
||||||||||
257 | foreach ($this->repository->getMigrations() as $migration) { |
||||||||||
258 | $migrationData = $this->migrationTable($migration->getDatabase()) |
||||||||||
259 | ->select('id') |
||||||||||
260 | ->where( |
||||||||||
261 | [ |
||||||||||
262 | 'migration' => $migration->getState()->getName(), |
||||||||||
263 | 'created_at' => null, |
||||||||||
264 | ] |
||||||||||
265 | ) |
||||||||||
266 | ->run() |
||||||||||
267 | ->fetch(); |
||||||||||
268 | |||||||||||
269 | if (!empty($migrationData)) { |
||||||||||
270 | $this->migrationTable($migration->getDatabase()) |
||||||||||
271 | ->update( |
||||||||||
272 | ['created_at' => $this->getMigrationCreatedAtForDb($migration)], |
||||||||||
273 | ['id' => $migrationData['id']] |
||||||||||
274 | ) |
||||||||||
275 | ->run(); |
||||||||||
276 | } |
||||||||||
277 | } |
||||||||||
278 | } |
||||||||||
279 | |||||||||||
280 | /** |
||||||||||
281 | * Check if some data modification required |
||||||||||
282 | */ |
||||||||||
283 | 336 | protected function isRestoreMigrationDataRequired(): bool |
|||||||||
284 | { |
||||||||||
285 | 336 | foreach ($this->dbal->getDatabases() as $db) { |
|||||||||
286 | 336 | $table = $db->table($this->config->getTable()); |
|||||||||
287 | |||||||||||
288 | if ( |
||||||||||
289 | 336 | $table->select('id') |
|||||||||
290 | 336 | ->where(['created_at' => null]) |
|||||||||
291 | 336 | ->count() > 0 |
|||||||||
292 | ) { |
||||||||||
293 | return true; |
||||||||||
294 | } |
||||||||||
295 | } |
||||||||||
296 | |||||||||||
297 | 336 | return false; |
|||||||||
298 | } |
||||||||||
299 | |||||||||||
300 | 296 | protected function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface |
|||||||||
301 | { |
||||||||||
302 | 296 | $db = $this->dbal->database($migration->getDatabase()); |
|||||||||
303 | |||||||||||
304 | 296 | return \DateTimeImmutable::createFromFormat( |
|||||||||
305 | self::DB_DATE_FORMAT, |
||||||||||
306 | 296 | $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT), |
|||||||||
307 | 296 | $db->getDriver()->getTimezone() |
|||||||||
308 | ); |
||||||||||
309 | } |
||||||||||
310 | } |
||||||||||
311 |