1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
|
<?php
/**
* The Minz_Migrator helps to migrate data (in a database or not) or the
* architecture of a Minz application.
*
* @author Marien Fressinaud <dev@marienfressinaud.fr>
* @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL
*/
class Minz_Migrator
{
/** @var string[] */
private $applied_versions;
/** @var array<string> */
private $migrations = [];
/**
* Execute a list of migrations, skipping versions indicated in a file
*
* @param string $migrations_path
* @param string $applied_migrations_path
*
* @return true|string Returns true if execute succeeds to apply
* migrations, or a string if it fails.
* @throws DomainException if there is no migrations corresponding to the
* given version (can happen if version file has
* been modified, or migrations path cannot be
* read).
*
* @throws BadFunctionCallException if a callback isn't callable.
*/
public static function execute(string $migrations_path, string $applied_migrations_path) {
$applied_migrations = @file_get_contents($applied_migrations_path);
if ($applied_migrations === false) {
return "Cannot open the {$applied_migrations_path} file";
}
$applied_migrations = array_filter(explode("\n", $applied_migrations));
$migration_files = scandir($migrations_path);
$migration_files = array_filter($migration_files, static function (string $filename) {
$file_extension = pathinfo($filename, PATHINFO_EXTENSION);
return $file_extension === 'php';
});
$migration_versions = array_map(static function (string $filename) {
return basename($filename, '.php');
}, $migration_files);
// We apply a "low-cost" comparison to avoid to include the migration
// files at each run. It is equivalent to the upToDate method.
if (count($applied_migrations) === count($migration_versions) &&
empty(array_diff($applied_migrations, $migration_versions))) {
// already at the latest version, so there is nothing more to do
return true;
}
$lock_path = $applied_migrations_path . '.lock';
if (!@mkdir($lock_path, 0770, true)) {
// Someone is probably already executing the migrations (the folder
// already exists).
// We should probably return something else, but we don't want the
// user to think there is an error (it's normal workflow), so let's
// stick to this solution for now.
// Another option would be to show him a maintenance page.
Minz_Log::warning(
'A request has been served while the application wasn’t up-to-date. '
. 'Too many of these errors probably means a previous migration failed.'
);
return true;
}
$migrator = new self($migrations_path);
if ($applied_migrations) {
$migrator->setAppliedVersions($applied_migrations);
}
$results = $migrator->migrate();
foreach ($results as $migration => $result) {
if ($result === true) {
$result = 'OK';
} elseif ($result === false) {
$result = 'KO';
}
Minz_Log::notice("Migration {$migration}: {$result}");
}
$applied_versions = implode("\n", $migrator->appliedVersions());
$saved = file_put_contents($applied_migrations_path, $applied_versions);
if (!@rmdir($lock_path)) {
Minz_Log::error(
'We weren’t able to unlink the migration executing folder, '
. 'you might want to delete yourself: ' . $lock_path
);
// we don't return early because the migrations could have been
// applied successfully. This file is not "critical" if not removed
// and more errors will eventually appear in the logs.
}
if ($saved === false) {
return "Cannot save the {$applied_migrations_path} file";
}
if (!$migrator->upToDate()) {
// still not up to date? It means last migration failed.
return trim('A migration failed to be applied, please see previous logs.' . "\n" . implode("\n", $results));
}
return true;
}
/**
* Create a Minz_Migrator instance. If directory is given, it'll load the
* migrations from it.
*
* All the files in the directory must declare a class named
* <app_name>_Migration_<filename> with a static `migrate` method.
*
* - <app_name> is the application name declared in the APP_NAME constant
* - <filename> is the migration file name, without the `.php` extension
*
* The files starting with a dot are ignored.
*
* @throws BadFunctionCallException if a callback isn't callable (i.e.
* cannot call a migrate method).
*/
public function __construct(?string $directory = null) {
$this->applied_versions = [];
if ($directory == null || !is_dir($directory)) {
return;
}
foreach (scandir($directory) as $filename) {
$file_extension = pathinfo($filename, PATHINFO_EXTENSION);
if ($file_extension !== 'php') {
continue;
}
$filepath = $directory . '/' . $filename;
$migration_version = basename($filename, '.php');
$migration_class = APP_NAME . "_Migration_" . $migration_version;
$migration_callback = $migration_class . '::migrate';
$include_result = @include_once($filepath);
if (!$include_result) {
Minz_Log::error(
"{$filepath} migration file cannot be loaded.",
ADMIN_LOG
);
}
$this->addMigration($migration_version, $migration_callback);
}
}
/**
* Register a migration into the migration system.
*
* @param string $version The version of the migration (be careful, migrations
* are sorted with the `strnatcmp` function)
* @param ?callable $callback The migration function to execute, it should
* return true on success and must return false
* on error
*
* @throws BadFunctionCallException if the callback isn't callable.
*/
public function addMigration(string $version, ?callable $callback): void {
if (!is_callable($callback)) {
throw new BadFunctionCallException("{$version} migration cannot be called.");
}
$this->migrations[$version] = $callback;
}
/**
* Return the list of migrations, sorted with `strnatcmp`
*
* @see https://www.php.net/manual/en/function.strnatcmp.php
*
* @return array<string,callable>
*/
public function migrations(): array {
$migrations = $this->migrations;
uksort($migrations, 'strnatcmp');
return $migrations;
}
/**
* Set the applied versions of the application.
*
* @param array<string> $versions
*
* @throws DomainException if there is no migrations corresponding to a version
*/
public function setAppliedVersions(array $versions): void {
foreach ($versions as $version) {
$version = trim($version);
if (!isset($this->migrations[$version])) {
throw new DomainException("{$version} migration does not exist.");
}
$this->applied_versions[] = $version;
}
}
/**
* @return string[]
*/
public function appliedVersions(): array {
$versions = $this->applied_versions;
usort($versions, 'strnatcmp');
return $versions;
}
/**
* Return the list of available versions, sorted with `strnatcmp`
*
* @see https://www.php.net/manual/en/function.strnatcmp.php
*
* @return string[]
*/
public function versions(): array {
$migrations = $this->migrations();
return array_keys($migrations);
}
/**
* @return bool Return true if the application is up-to-date, false otherwise.
* If no migrations are registered, it always returns true.
*/
public function upToDate(): bool {
// Counting versions is enough since we cannot apply a version which
// doesn't exist (see setAppliedVersions method).
return count($this->versions()) === count($this->applied_versions);
}
/**
* Migrate the system to the latest version.
*
* It only executes migrations AFTER the current version. If a migration
* returns false or fails, it immediately stops the process.
*
* If the migration doesn't return false nor raise an exception, it is
* considered as successful. It is considered as good practice to return
* true on success though.
*
* @return array<string|bool> Return the results of each executed migration. If an
* exception was raised in a migration, its result is set to
* the exception message.
*/
public function migrate(): array {
$result = [];
foreach ($this->migrations() as $version => $callback) {
if (in_array($version, $this->applied_versions)) {
// the version is already applied so we skip this migration
continue;
}
try {
$migration_result = $callback();
$result[$version] = $migration_result;
} catch (Exception $e) {
$migration_result = false;
$result[$version] = $e->getMessage();
}
if ($migration_result === false) {
break;
}
$this->applied_versions[] = $version;
}
return $result;
}
}
|