Skip to content

Commit d31c4d6

Browse files
committed
Add fingerprint-keyed schema cache
1 parent dace7df commit d31c4d6

4 files changed

Lines changed: 223 additions & 24 deletions

File tree

‎src/Database/SchemaCache.php‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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\PHPStan\Database\Schema\Schema;
17+
18+
/**
19+
* Builds and caches the project's database schema as a var_export'd PHP file, rebuilding it when
20+
* the migration fingerprint changes.
21+
*/
22+
final class SchemaCache
23+
{
24+
public function __construct(
25+
private readonly string $cacheDirectory,
26+
private readonly SchemaConnectionFactory $connectionFactory,
27+
private readonly SchemaMigrator $migrator,
28+
private readonly SchemaIntrospector $introspector,
29+
) {}
30+
31+
public function get(?string $namespace = null): Schema
32+
{
33+
$cacheFile = $this->cacheDirectory . '/schema.php';
34+
$databasePath = $this->cacheDirectory . '/' . uniqid('schema-build-', true) . '.sqlite';
35+
$db = $this->connectionFactory->create($databasePath);
36+
37+
try {
38+
$fingerprint = $this->migrator->fingerprint($db, $namespace);
39+
40+
$cached = is_file($cacheFile) ? include $cacheFile : null;
41+
42+
if ($cached instanceof Schema && $cached->hash === $fingerprint) {
43+
return $cached;
44+
}
45+
46+
$this->migrator->migrate($db, $namespace);
47+
$schema = $this->introspector->introspect($db, $fingerprint);
48+
$this->store($cacheFile, $schema);
49+
50+
return $schema;
51+
} finally {
52+
$db->close();
53+
54+
if (is_file($databasePath)) {
55+
unlink($databasePath);
56+
}
57+
}
58+
}
59+
60+
private function store(string $cacheFile, Schema $schema): void
61+
{
62+
$temporaryFile = $cacheFile . '.' . uniqid('', true) . '.tmp';
63+
64+
file_put_contents($temporaryFile, '<?php return ' . var_export($schema, true) . ";\n");
65+
rename($temporaryFile, $cacheFile);
66+
}
67+
}

‎src/Database/SchemaMigrator.php‎

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,43 +23,45 @@
2323
use Throwable;
2424

2525
/**
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.
26+
* Discovers and runs the project's migrations against a schema connection, skipping migrations
27+
* pinned to another database group and tolerating migrations that fail to run.
3228
*/
3329
final class SchemaMigrator
3430
{
35-
public function migrate(Connection $db, ?string $namespace = null): string
31+
/**
32+
* Fingerprint of the migration set, computed from the files without running them.
33+
*/
34+
public function fingerprint(Connection $db, ?string $namespace = null): string
3635
{
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-
4536
$fingerprint = '';
4637

47-
foreach ($runner->findMigrations() as $migration) {
48-
if (! $migration instanceof stdClass) {
38+
foreach ($this->discoverMigrations($db, $namespace) as $migration) {
39+
$path = $migration->path;
40+
$uid = $migration->uid;
41+
42+
if (! is_string($path) || ! is_string($uid)) {
4943
continue;
5044
}
5145

46+
$fileHash = md5_file($path);
47+
$fingerprint .= $uid . ':' . ($fileHash === false ? '' : $fileHash) . "\n";
48+
}
49+
50+
return hash('sha256', $fingerprint);
51+
}
52+
53+
public function migrate(Connection $db, ?string $namespace = null): void
54+
{
55+
$forge = Database::forge($db);
56+
57+
foreach ($this->discoverMigrations($db, $namespace) as $migration) {
5258
$path = $migration->path;
5359
$class = $migration->class;
54-
$uid = $migration->uid;
5560

56-
if (! is_string($path) || ! is_string($class) || ! is_string($uid)) {
61+
if (! is_string($path) || ! is_string($class)) {
5762
continue;
5863
}
5964

60-
$fileHash = md5_file($path);
61-
$fingerprint .= $uid . ':' . ($fileHash === false ? '' : $fileHash) . "\n";
62-
6365
require_once $path;
6466

6567
if (! class_exists($class, false) || $this->isPinnedToGroup($class)) {
@@ -78,8 +80,23 @@ public function migrate(Connection $db, ?string $namespace = null): string
7880
// Degrade: the failed migration's tables are simply absent from the schema.
7981
}
8082
}
83+
}
8184

82-
return hash('sha256', $fingerprint);
85+
/**
86+
* @return list<stdClass>
87+
*/
88+
private function discoverMigrations(Connection $db, ?string $namespace): array
89+
{
90+
$runner = new MigrationRunner(config(Migrations::class), $db);
91+
92+
if ($namespace !== null) {
93+
$runner->setNamespace($namespace);
94+
}
95+
96+
return array_values(array_filter(
97+
$runner->findMigrations(),
98+
static fn (mixed $migration): bool => $migration instanceof stdClass,
99+
));
83100
}
84101

85102
/**

‎tests/Database/SchemaCacheTest.php‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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\PHPStan\Database\Schema\Schema;
17+
use CodeIgniter\PHPStan\Database\Schema\Table;
18+
use CodeIgniter\PHPStan\Database\SchemaCache;
19+
use CodeIgniter\PHPStan\Database\SchemaConnectionFactory;
20+
use CodeIgniter\PHPStan\Database\SchemaIntrospector;
21+
use CodeIgniter\PHPStan\Database\SchemaMigrator;
22+
use PHPUnit\Framework\Attributes\Group;
23+
use PHPUnit\Framework\TestCase;
24+
25+
/**
26+
* @internal
27+
*/
28+
#[Group('unit')]
29+
final class SchemaCacheTest extends TestCase
30+
{
31+
private const FIXTURE_NAMESPACE = 'CodeIgniter\\PHPStan\\Tests\\Fixtures';
32+
33+
private string $cacheDirectory;
34+
35+
protected function setUp(): void
36+
{
37+
parent::setUp();
38+
39+
service('autoloader')->addNamespace(self::FIXTURE_NAMESPACE, dirname(__DIR__) . '/Fixtures');
40+
41+
$this->cacheDirectory = dirname(__DIR__, 2) . '/tmp/' . uniqid('schema-cache-', true);
42+
mkdir($this->cacheDirectory, 0775, true);
43+
}
44+
45+
protected function tearDown(): void
46+
{
47+
$files = glob($this->cacheDirectory . '/*');
48+
49+
if ($files !== false) {
50+
foreach ($files as $file) {
51+
unlink($file);
52+
}
53+
}
54+
55+
rmdir($this->cacheDirectory);
56+
57+
parent::tearDown();
58+
}
59+
60+
public function testBuildsSchemaOnMissAndWritesCacheFile(): void
61+
{
62+
$schema = $this->createCache()->get(self::FIXTURE_NAMESPACE);
63+
64+
self::assertNotNull($schema->getTable('blog_users'));
65+
self::assertNotNull($schema->getTable('blog_posts'));
66+
self::assertFileExists($this->cacheDirectory . '/schema.php');
67+
}
68+
69+
public function testReadsFromCacheOnHitWithoutRebuilding(): void
70+
{
71+
$cache = $this->createCache();
72+
73+
$built = $cache->get(self::FIXTURE_NAMESPACE);
74+
75+
// Overwrite the cache file with a sentinel that keeps the same fingerprint. A second get()
76+
// must return it, which is only possible if it reads the cache instead of rebuilding.
77+
$sentinel = new Schema(hash: $built->hash, tables: ['sentinel' => new Table('sentinel', [])]);
78+
$this->writeCache($sentinel);
79+
80+
$reloaded = $cache->get(self::FIXTURE_NAMESPACE);
81+
82+
self::assertNotNull($reloaded->getTable('sentinel'));
83+
self::assertNull($reloaded->getTable('blog_users'));
84+
}
85+
86+
public function testRebuildsWhenTheFingerprintChanges(): void
87+
{
88+
// Seed the cache with a schema whose hash no longer matches the migration fingerprint.
89+
$this->writeCache(new Schema(hash: 'stale-fingerprint', tables: ['old' => new Table('old', [])]));
90+
91+
$schema = $this->createCache()->get(self::FIXTURE_NAMESPACE);
92+
93+
self::assertNull($schema->getTable('old'));
94+
self::assertNotNull($schema->getTable('blog_users'));
95+
self::assertNotSame('stale-fingerprint', $schema->hash);
96+
}
97+
98+
private function writeCache(Schema $schema): void
99+
{
100+
file_put_contents($this->cacheDirectory . '/schema.php', '<?php return ' . var_export($schema, true) . ';');
101+
}
102+
103+
private function createCache(): SchemaCache
104+
{
105+
return new SchemaCache(
106+
$this->cacheDirectory,
107+
new SchemaConnectionFactory(),
108+
new SchemaMigrator(),
109+
new SchemaIntrospector(),
110+
);
111+
}
112+
}

‎tests/Database/SchemaMigratorTest.php‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,13 @@ protected function tearDown(): void
5555

5656
public function testRunsMigrationsResilientlyAndReturnsFingerprint(): void
5757
{
58-
$fingerprint = (new SchemaMigrator())->migrate($this->db, self::FIXTURE_NAMESPACE);
58+
$migrator = new SchemaMigrator();
59+
$fingerprint = $migrator->fingerprint($this->db, self::FIXTURE_NAMESPACE);
5960

6061
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $fingerprint);
6162

63+
$migrator->migrate($this->db, self::FIXTURE_NAMESPACE);
64+
6265
$schema = (new SchemaIntrospector())->introspect($this->db, $fingerprint);
6366

6467
// Plain migrations are applied, including the one after the failing migration.

0 commit comments

Comments
 (0)