Skip to content

Commit 90fa20c

Browse files
erikaraujobrendt
andauthored
feat(database): add migration hash checking (#1054)
Co-authored-by: Brent Roose <[email protected]>
1 parent ad8cd15 commit 90fa20c

19 files changed

+484
-21
lines changed

src/Tempest/Console/src/Input/MemoryInputBuffer.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,11 @@ private function consumeBuffer(): string
5252

5353
return $next ?? '';
5454
}
55+
56+
public function clear(): self
57+
{
58+
$this->buffer = [];
59+
60+
return $this;
61+
}
5562
}

src/Tempest/Console/src/Testing/ConsoleTester.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ final class ConsoleTester
3535

3636
private bool $withPrompting = true;
3737

38+
private (Console&GenericConsole)|null $console = null;
39+
3840
public function __construct(
3941
private readonly Container $container,
4042
) {}
@@ -43,20 +45,26 @@ public function call(string|Closure|array $command, string|array $arguments = []
4345
{
4446
$clone = clone $this;
4547

46-
$memoryOutputBuffer = new MemoryOutputBuffer();
48+
$this->output ??= new MemoryOutputBuffer();
49+
$this->output->clear();
50+
$memoryOutputBuffer = $this->output;
4751
$clone->container->singleton(OutputBuffer::class, $memoryOutputBuffer);
4852

49-
$memoryInputBuffer = new MemoryInputBuffer();
53+
$this->input ??= new MemoryInputBuffer();
54+
$this->input->clear();
55+
$memoryInputBuffer = $this->input;
5056
$clone->container->singleton(InputBuffer::class, $memoryInputBuffer);
5157

52-
$console = new GenericConsole(
58+
$this->console ??= new GenericConsole(
5359
output: $memoryOutputBuffer,
5460
input: $memoryInputBuffer,
5561
highlighter: $clone->container->get(Highlighter::class, 'console'),
5662
executeConsoleCommand: $clone->container->get(ExecuteConsoleCommand::class),
5763
argumentBag: $clone->container->get(ConsoleArgumentBag::class),
5864
);
5965

66+
$console = $this->console;
67+
6068
if ($this->withPrompting === false) {
6169
$console->disablePrompting();
6270
}

src/Tempest/Database/src/Migrations/CreateMigrationsTable.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ public function up(): QueryStatement
1717
{
1818
return CreateTableStatement::forModel(Migration::class)
1919
->primary()
20-
->text('name');
20+
->text('name')
21+
->varchar('hash', 32);
2122
}
2223

2324
public function down(): QueryStatement

src/Tempest/Database/src/Migrations/Migration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ final class Migration
1111
use IsDatabaseModel;
1212

1313
public string $name;
14+
15+
public string $hash;
1416
}

src/Tempest/Database/src/Migrations/MigrationException.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@
66

77
use Exception;
88

9-
final class MigrationException extends Exception
9+
interface MigrationException
1010
{
11-
public static function noTable(): self
12-
{
13-
return new self('Migrations table does not exist. Nothing to roll back.');
14-
}
1511
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Migrations;
6+
7+
use Exception;
8+
9+
final class MigrationHashMismatchException extends Exception implements MigrationException
10+
{
11+
public function __construct(
12+
string $message = 'Migration file has been tampered with.',
13+
) {
14+
parent::__construct($message);
15+
}
16+
}

src/Tempest/Database/src/Migrations/MigrationManager.php

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
use Tempest\Database\Config\DatabaseDialect;
1111
use Tempest\Database\Database;
1212
use Tempest\Database\DatabaseMigration as MigrationInterface;
13+
use Tempest\Database\DatabaseMigration;
1314
use Tempest\Database\Exceptions\QueryException;
1415
use Tempest\Database\Query;
16+
use Tempest\Database\QueryStatement;
1517
use Tempest\Database\QueryStatements\DropTableStatement;
1618
use Tempest\Database\QueryStatements\SetForeignKeyChecksStatement;
1719
use Tempest\Database\QueryStatements\ShowTablesStatement;
@@ -60,7 +62,7 @@ public function down(): void
6062
/** @throw UnhandledMatchError */
6163
match ((string) $pdoException->getCode()) {
6264
$this->databaseConfig->dialect->tableNotFoundCode() => event(
63-
event: new MigrationFailed(name: new ModelDefinition(Migration::class)->getTableDefinition()->name, exception: MigrationException::noTable()),
65+
event: new MigrationFailed(name: new ModelDefinition(Migration::class)->getTableDefinition()->name, exception: new TableNotFoundException()),
6466
),
6567
default => throw new UnhandledMatchError($pdoException->getMessage()),
6668
};
@@ -108,6 +110,65 @@ public function dropAll(): void
108110
}
109111
}
110112

113+
public function rehashAll(): void
114+
{
115+
try {
116+
$existingMigrations = Migration::all();
117+
} catch (PDOException) {
118+
return;
119+
}
120+
121+
foreach ($existingMigrations as $existingMigration) {
122+
/**
123+
* We need to find and delete migration DB records that no longer have a corresponding migration file.
124+
* This can happen if a migration file was deleted or renamed.
125+
* If we don't do it, `:validate` will continue failing due to the missing migration file.
126+
*/
127+
$databaseMigration = array_find(
128+
iterator_to_array($this->migrations),
129+
static fn (DatabaseMigration $migration) => $migration->name === $existingMigration->name,
130+
);
131+
132+
if ($databaseMigration === null) {
133+
$existingMigration->delete();
134+
135+
continue;
136+
}
137+
138+
$existingMigration->update(
139+
hash: $this->getMigrationHash($databaseMigration),
140+
);
141+
}
142+
}
143+
144+
public function validate(): void
145+
{
146+
try {
147+
$existingMigrations = Migration::all();
148+
} catch (PDOException) {
149+
return;
150+
}
151+
152+
foreach ($existingMigrations as $existingMigration) {
153+
$databaseMigration = array_find(
154+
iterator_to_array($this->migrations),
155+
static fn (DatabaseMigration $migration) => $migration->name === $existingMigration->name,
156+
);
157+
158+
if ($databaseMigration === null) {
159+
event(new MigrationValidationFailed($existingMigration->name, new MissingMigrationFileException()));
160+
161+
continue;
162+
}
163+
164+
if ($this->getMigrationHash($databaseMigration) !== $existingMigration->hash) {
165+
event(new MigrationValidationFailed($existingMigration->name, new MigrationHashMismatchException()));
166+
167+
continue;
168+
}
169+
}
170+
}
171+
111172
public function executeUp(MigrationInterface $migration): void
112173
{
113174
$statement = $migration->up();
@@ -125,6 +186,7 @@ public function executeUp(MigrationInterface $migration): void
125186

126187
Migration::create(
127188
name: $migration->name,
189+
hash: $this->getMigrationHash($migration),
128190
);
129191
} catch (PDOException $pdoException) {
130192
event(new MigrationFailed($migration->name, $pdoException));
@@ -198,4 +260,30 @@ private function getTableDefinitions(DatabaseDialect $dialect): array
198260
new ShowTablesStatement()->fetch($dialect),
199261
);
200262
}
263+
264+
private function getMigrationHash(DatabaseMigration $migration): string
265+
{
266+
$minifiedDownSql = $this->getMinifiedSqlFromStatement($migration->down());
267+
$minifiedUpSql = $this->getMinifiedSqlFromStatement($migration->up());
268+
269+
return hash('xxh128', $minifiedDownSql . $minifiedUpSql);
270+
}
271+
272+
private function getMinifiedSqlFromStatement(?QueryStatement $statement): string
273+
{
274+
if ($statement === null) {
275+
return '';
276+
}
277+
278+
$query = new Query($statement->compile($this->databaseConfig->dialect));
279+
280+
// Remove comments
281+
$sql = preg_replace('/--.*$/m', '', $query->getSql()); // Remove SQL single-line comments
282+
$sql = preg_replace('/\/\*[\s\S]*?\*\//', '', $sql); // Remove block comments
283+
284+
// Remove blank lines and excessive spaces
285+
$sql = preg_replace('/\s+/', ' ', trim($sql));
286+
287+
return $sql;
288+
}
201289
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Migrations;
6+
7+
use Throwable;
8+
9+
final readonly class MigrationValidationFailed
10+
{
11+
public function __construct(
12+
public string $name,
13+
public Throwable $exception,
14+
) {}
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Migrations;
6+
7+
use Exception;
8+
9+
final class MissingMigrationFileException extends Exception implements MigrationException
10+
{
11+
public function __construct(
12+
string $message = 'Migration file is missing.',
13+
) {
14+
parent::__construct($message);
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Migrations;
6+
7+
use Exception;
8+
9+
final class TableNotFoundException extends Exception implements MigrationException
10+
{
11+
public function __construct(
12+
string $message = 'Migrations table does not exist. Nothing to roll back.',
13+
) {
14+
parent::__construct($message);
15+
}
16+
}

0 commit comments

Comments
 (0)