Issues (15)

src/Migrator.php (4 issues)

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
The method getDatabases() does not exist on Cycle\Database\DatabaseProviderInterface. It seems like you code against a sub-type of Cycle\Database\DatabaseProviderInterface such as Cycle\Database\DatabaseManager. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

46
        foreach ($this->dbal->/** @scrutinizer ignore-call */ getDatabases() as $db) {
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 ignore-call  annotation

121
                $capsule->/** @scrutinizer ignore-call */ 
122
                          getDatabase($migration->getDatabase())->transaction(

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 ignore-call  annotation

121
                $capsule->/** @scrutinizer ignore-call */ 
122
                          getDatabase($migration->getDatabase())->transaction(

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
Bug Best Practice introduced by
The expression return $this->dbal->data...is->config->getTable()) returns the type Cycle\Database\TableInterface which includes types incompatible with the type-hinted return Cycle\Database\Table.
Loading history...
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