Skip to content

Commit dace7df

Browse files
committed
Add resilient migration runner and schema fingerprint
1 parent 98db48e commit dace7df

6 files changed

Lines changed: 304 additions & 0 deletions

File tree

‎src/Database/SchemaMigrator.php‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database;
15+
16+
use CodeIgniter\Database\Migration;
17+
use CodeIgniter\Database\MigrationRunner;
18+
use CodeIgniter\Database\SQLite3\Connection;
19+
use Config\Database;
20+
use Config\Migrations;
21+
use ReflectionClass;
22+
use stdClass;
23+
use Throwable;
24+
25+
/**
26+
* Runs the project's migrations into a schema connection and returns a fingerprint
27+
* of the migration set.
28+
*
29+
* Migrations are run one by one rather than through `MigrationRunner::latest()`: a migration
30+
* that cannot run on SQLite leaves its tables out of the inferred schema instead of aborting
31+
* the whole build, and migrations pinned to a specific database group are skipped.
32+
*/
33+
final class SchemaMigrator
34+
{
35+
public function migrate(Connection $db, ?string $namespace = null): string
36+
{
37+
$runner = new MigrationRunner(config(Migrations::class), $db);
38+
39+
if ($namespace !== null) {
40+
$runner->setNamespace($namespace);
41+
}
42+
43+
$forge = Database::forge($db);
44+
45+
$fingerprint = '';
46+
47+
foreach ($runner->findMigrations() as $migration) {
48+
if (! $migration instanceof stdClass) {
49+
continue;
50+
}
51+
52+
$path = $migration->path;
53+
$class = $migration->class;
54+
$uid = $migration->uid;
55+
56+
if (! is_string($path) || ! is_string($class) || ! is_string($uid)) {
57+
continue;
58+
}
59+
60+
$fileHash = md5_file($path);
61+
$fingerprint .= $uid . ':' . ($fileHash === false ? '' : $fileHash) . "\n";
62+
63+
require_once $path;
64+
65+
if (! class_exists($class, false) || $this->isPinnedToGroup($class)) {
66+
continue;
67+
}
68+
69+
$instance = new $class($forge);
70+
71+
if (! $instance instanceof Migration) {
72+
continue;
73+
}
74+
75+
try {
76+
$instance->up();
77+
} catch (Throwable) {
78+
// Degrade: the failed migration's tables are simply absent from the schema.
79+
}
80+
}
81+
82+
return hash('sha256', $fingerprint);
83+
}
84+
85+
/**
86+
* Reads the migration's `$DBGroup` default without instantiating it, since instantiating a
87+
* pinned migration would open a connection to that group.
88+
*
89+
* @param class-string $class
90+
*/
91+
private function isPinnedToGroup(string $class): bool
92+
{
93+
return ((new ReflectionClass($class))->getDefaultProperties()['DBGroup'] ?? null) !== null;
94+
}
95+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Database;
15+
16+
use CodeIgniter\Database\SQLite3\Connection;
17+
use CodeIgniter\PHPStan\Database\SchemaConnectionFactory;
18+
use CodeIgniter\PHPStan\Database\SchemaIntrospector;
19+
use CodeIgniter\PHPStan\Database\SchemaMigrator;
20+
use PHPUnit\Framework\Attributes\Group;
21+
use PHPUnit\Framework\TestCase;
22+
23+
/**
24+
* @internal
25+
*/
26+
#[Group('unit')]
27+
final class SchemaMigratorTest extends TestCase
28+
{
29+
private const FIXTURE_NAMESPACE = 'CodeIgniter\\PHPStan\\Tests\\Fixtures';
30+
31+
private string $databasePath;
32+
private Connection $db;
33+
34+
protected function setUp(): void
35+
{
36+
parent::setUp();
37+
38+
service('autoloader')->addNamespace(self::FIXTURE_NAMESPACE, dirname(__DIR__) . '/Fixtures');
39+
40+
$this->databasePath = dirname(__DIR__, 2) . '/tmp/' . uniqid('schema-migrator-', true) . '.sqlite';
41+
42+
$this->db = (new SchemaConnectionFactory())->create($this->databasePath);
43+
}
44+
45+
protected function tearDown(): void
46+
{
47+
$this->db->close();
48+
49+
if (is_file($this->databasePath)) {
50+
unlink($this->databasePath);
51+
}
52+
53+
parent::tearDown();
54+
}
55+
56+
public function testRunsMigrationsResilientlyAndReturnsFingerprint(): void
57+
{
58+
$fingerprint = (new SchemaMigrator())->migrate($this->db, self::FIXTURE_NAMESPACE);
59+
60+
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $fingerprint);
61+
62+
$schema = (new SchemaIntrospector())->introspect($this->db, $fingerprint);
63+
64+
// Plain migrations are applied, including the one after the failing migration.
65+
self::assertNotNull($schema->getTable('blog_users'));
66+
self::assertNotNull($schema->getTable('blog_posts'));
67+
68+
// A migration pinned to another database group is skipped.
69+
self::assertNull($schema->getTable('blog_groups'));
70+
}
71+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Database\Migrations;
15+
16+
use CodeIgniter\Database\Migration;
17+
18+
final class CreateBlogUsers extends Migration
19+
{
20+
public function up(): void
21+
{
22+
$this->forge->addField([
23+
'id' => ['type' => 'INTEGER', 'auto_increment' => true],
24+
'name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
25+
]);
26+
$this->forge->addKey('id', true);
27+
$this->forge->createTable('blog_users');
28+
}
29+
30+
public function down(): void
31+
{
32+
$this->forge->dropTable('blog_users');
33+
}
34+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Database\Migrations;
15+
16+
use CodeIgniter\Database\Migration;
17+
18+
final class CreateBlogGroups extends Migration
19+
{
20+
protected $DBGroup = 'other';
21+
22+
public function up(): void
23+
{
24+
$this->forge->addField([
25+
'id' => ['type' => 'INTEGER', 'auto_increment' => true],
26+
'name' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
27+
]);
28+
$this->forge->addKey('id', true);
29+
$this->forge->createTable('blog_groups');
30+
}
31+
32+
public function down(): void
33+
{
34+
$this->forge->dropTable('blog_groups');
35+
}
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Database\Migrations;
15+
16+
use CodeIgniter\Database\Migration;
17+
use RuntimeException;
18+
19+
/**
20+
* Stands in for a migration that cannot run on SQLite (e.g. raw vendor SQL).
21+
*/
22+
final class FailBlogTable extends Migration
23+
{
24+
public function up(): void
25+
{
26+
throw new RuntimeException('Simulated non-portable migration.');
27+
}
28+
29+
public function down(): void
30+
{
31+
// Nothing to undo: up() never completes.
32+
}
33+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Database\Migrations;
15+
16+
use CodeIgniter\Database\Migration;
17+
18+
final class CreateBlogPosts extends Migration
19+
{
20+
public function up(): void
21+
{
22+
$this->forge->addField([
23+
'id' => ['type' => 'INTEGER', 'auto_increment' => true],
24+
'user_id' => ['type' => 'INTEGER', 'null' => false],
25+
'title' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
26+
]);
27+
$this->forge->addKey('id', true);
28+
$this->forge->createTable('blog_posts');
29+
}
30+
31+
public function down(): void
32+
{
33+
$this->forge->dropTable('blog_posts');
34+
}
35+
}

0 commit comments

Comments
 (0)