Skip to content

Commit b31c474

Browse files
abnegateclaude
andcommitted
feat(migration): add the migration runner and schema differ
Splits out of the query-lib migration, which had carried these along with it. Migration gives versioned up/down migrations with a Runner, an online schema change path and a Generator that scaffolds a migration file. Schema gives the introspector and the Diff that reports what changed between a declared collection and the one the engine holds. The two are a closed loop: Schema serves Migration\Generator, and bin/tasks/migrate.php is the only entry point, registered from bin/cli.php. Stacked on feat-query-lib rather than main: both are written against the Attribute, Collection and Index value objects that migration introduces. loadMigrations() discovers migration classes by what the file declares rather than by its filename, because migrate:generate writes the class under a namespace and a filename lookup silently found nothing -- the run reported success having skipped the migration. Two tests in CLITasksTest cover that, and they must run after the test that includes bin/tasks/migrate.php, since a second include redeclares the function. Nothing outside this library consumes it. Neither appwrite nor cloud references Utopia\Database\Migration or Utopia\Database\Schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c6ae48d commit b31c474

18 files changed

Lines changed: 1828 additions & 0 deletions

‎bin/cli.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
include 'tasks/query.php';
7070
include 'tasks/relationships.php';
7171
include 'tasks/operators.php';
72+
include 'tasks/migrate.php';
7273

7374
$cli
7475
->error()

‎bin/tasks/migrate.php‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
/**
4+
* @var CLI $cli
5+
*/
6+
global $cli;
7+
8+
use Utopia\CLI\CLI;
9+
use Utopia\Console;
10+
use Utopia\Database\Database;
11+
use Utopia\Database\Migration\Generator;
12+
use Utopia\Database\Migration\Migration;
13+
use Utopia\Database\Migration\Runner;
14+
use Utopia\Validator\Boolean;
15+
use Utopia\Validator\Integer;
16+
use Utopia\Validator\Text;
17+
18+
/**
19+
* @Example
20+
* docker compose exec tests bin/cli migrate --adapter=mysql --name=testing --path=migrations
21+
* docker compose exec tests bin/cli migrate:rollback --adapter=mysql --name=testing --path=migrations --steps=1
22+
* docker compose exec tests bin/cli migrate:status --adapter=mysql --name=testing --path=migrations
23+
* docker compose exec tests bin/cli migrate:fresh --adapter=mysql --name=testing --path=migrations
24+
* docker compose exec tests bin/cli migrate:generate --name=add_users_table
25+
*/
26+
27+
$cli
28+
->task('migrate')
29+
->desc('Run pending database migrations')
30+
->param('path', 'migrations', new Text(0), 'Path to migration files', true)
31+
->param('adapter', '', new Text(0), 'Database adapter')
32+
->param('name', '', new Text(0), 'Database name')
33+
->param('namespace', '_ns', new Text(0), 'Database namespace', true)
34+
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
35+
->inject('database')
36+
->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) {
37+
$migrations = loadMigrations($path);
38+
39+
if ($migrations === []) {
40+
Console::warning('No migration files found in: ' . $path);
41+
42+
return;
43+
}
44+
45+
Console::info('Running migrations...');
46+
47+
$db = $database($adapter, $name, $namespace, $sharedTables);
48+
if (! $db instanceof Database) {
49+
throw new \RuntimeException('The database resource must return a Database instance.');
50+
}
51+
$runner = new Runner($db);
52+
$count = $runner->migrate($migrations);
53+
54+
Console::success("Ran {$count} migration(s).");
55+
});
56+
57+
$cli
58+
->task('migrate:rollback')
59+
->desc('Rollback the last batch of migrations')
60+
->param('path', 'migrations', new Text(0), 'Path to migration files', true)
61+
->param('steps', 1, new Integer(true), 'Number of batches to rollback', true)
62+
->param('adapter', '', new Text(0), 'Database adapter')
63+
->param('name', '', new Text(0), 'Database name')
64+
->param('namespace', '_ns', new Text(0), 'Database namespace', true)
65+
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
66+
->inject('database')
67+
->action(function (string $path, int $steps, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) {
68+
$migrations = loadMigrations($path);
69+
$db = $database($adapter, $name, $namespace, $sharedTables);
70+
if (! $db instanceof Database) {
71+
throw new \RuntimeException('The database resource must return a Database instance.');
72+
}
73+
$runner = new Runner($db);
74+
$count = $runner->rollback($migrations, $steps);
75+
76+
Console::success("Rolled back {$count} migration(s).");
77+
});
78+
79+
$cli
80+
->task('migrate:status')
81+
->desc('Show the status of all migrations')
82+
->param('path', 'migrations', new Text(0), 'Path to migration files', true)
83+
->param('adapter', '', new Text(0), 'Database adapter')
84+
->param('name', '', new Text(0), 'Database name')
85+
->param('namespace', '_ns', new Text(0), 'Database namespace', true)
86+
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
87+
->inject('database')
88+
->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) {
89+
$migrations = loadMigrations($path);
90+
$db = $database($adapter, $name, $namespace, $sharedTables);
91+
if (! $db instanceof Database) {
92+
throw new \RuntimeException('The database resource must return a Database instance.');
93+
}
94+
$runner = new Runner($db);
95+
$status = $runner->status($migrations);
96+
97+
Console::info(\str_pad('Version', 20) . \str_pad('Name', 40) . 'Applied');
98+
Console::info(\str_repeat('-', 70));
99+
100+
foreach ($status as $entry) {
101+
$applied = $entry['applied'] ? 'Yes' : 'No';
102+
Console::log(\str_pad($entry['version'], 20) . \str_pad($entry['name'], 40) . $applied);
103+
}
104+
});
105+
106+
$cli
107+
->task('migrate:fresh')
108+
->desc('Drop all collections and re-run all migrations')
109+
->param('path', 'migrations', new Text(0), 'Path to migration files', true)
110+
->param('adapter', '', new Text(0), 'Database adapter')
111+
->param('name', '', new Text(0), 'Database name')
112+
->param('namespace', '_ns', new Text(0), 'Database namespace', true)
113+
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
114+
->inject('database')
115+
->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) {
116+
$migrations = loadMigrations($path);
117+
$db = $database($adapter, $name, $namespace, $sharedTables);
118+
if (! $db instanceof Database) {
119+
throw new \RuntimeException('The database resource must return a Database instance.');
120+
}
121+
$runner = new Runner($db);
122+
123+
Console::warning('Dropping all collections and re-migrating...');
124+
$count = $runner->fresh($migrations);
125+
126+
Console::success("Fresh migration complete. Ran {$count} migration(s).");
127+
});
128+
129+
$cli
130+
->task('migrate:generate')
131+
->desc('Generate an empty migration file')
132+
->param('name', '', new Text(0), 'Migration name (e.g. add_users_table)')
133+
->param('path', 'migrations', new Text(0), 'Output directory', true)
134+
->action(function (string $name, string $path) {
135+
$timestamp = \date('YmdHis');
136+
$className = 'V' . $timestamp . '_' . \str_replace(' ', '', \ucwords(\str_replace('_', ' ', $name)));
137+
138+
$generator = new Generator();
139+
$content = $generator->generateEmpty($className);
140+
141+
if (! \is_dir($path)) {
142+
\mkdir($path, 0755, true);
143+
}
144+
145+
$filePath = $path . '/' . $className . '.php';
146+
\file_put_contents($filePath, $content);
147+
148+
Console::success("Created migration: {$filePath}");
149+
});
150+
151+
/**
152+
* @return array<Migration>
153+
*/
154+
function loadMigrations(string $path): array
155+
{
156+
if (! \is_dir($path)) {
157+
return [];
158+
}
159+
160+
$migrations = [];
161+
$files = \glob($path . '/*.php');
162+
163+
if ($files === false) {
164+
return [];
165+
}
166+
167+
foreach ($files as $file) {
168+
$before = \get_declared_classes();
169+
170+
require_once $file;
171+
172+
// migrate:generate writes the class under a namespace, so the file name
173+
// is not the class name and looking it up that way finds nothing --
174+
// silently, leaving the run reporting success having skipped it. Take
175+
// whatever the file declared instead of guessing at it.
176+
foreach (\array_diff(\get_declared_classes(), $before) as $className) {
177+
if (\is_subclass_of($className, Migration::class)) {
178+
$migrations[] = new $className();
179+
}
180+
}
181+
}
182+
183+
return $migrations;
184+
}

‎phpstan.neon‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ parameters:
77
- src
88
- tests
99
scanFiles:
10+
# Declares loadMigrations(), which tests/unit/CLITasksTest.php calls.
11+
- bin/tasks/migrate.php
1012
- stubs/Swoole/Database/DetectsLostConnections.stub.php
1113
- stubs/Swoole/Database/PDOProxy.stub.php
1214
- stubs/Swoole/Database/PDOStatementProxy.stub.php
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
namespace Utopia\Database\Migration;
4+
5+
use Utopia\Database\Schema\Change;
6+
use Utopia\Database\Schema\ChangeType;
7+
use Utopia\Database\Schema\DiffResult;
8+
9+
class Generator
10+
{
11+
public function generate(DiffResult $diff, string $className, string $namespace = 'App\\Migration'): string
12+
{
13+
$version = $this->extractVersion($className);
14+
$upLines = [];
15+
$downLines = [];
16+
17+
foreach ($diff->changes as $change) {
18+
$up = $this->generateUpStatement($change);
19+
$down = $this->generateDownStatement($change);
20+
21+
if ($up !== null) {
22+
$upLines[] = " {$up}";
23+
}
24+
25+
if ($down !== null) {
26+
$downLines[] = " {$down}";
27+
}
28+
}
29+
30+
$upBody = $upLines !== [] ? \implode("\n", $upLines) : ' // No changes';
31+
$downBody = $downLines !== [] ? \implode("\n", \array_reverse($downLines)) : ' // No changes';
32+
33+
return <<<PHP
34+
<?php
35+
36+
namespace {$namespace};
37+
38+
use Utopia\Database\Database;
39+
use Utopia\Database\Migration\Migration;
40+
41+
class {$className} extends Migration
42+
{
43+
public function version(): string
44+
{
45+
return '{$version}';
46+
}
47+
48+
public function up(Database \$db): void
49+
{
50+
{$upBody}
51+
}
52+
53+
public function down(Database \$db): void
54+
{
55+
{$downBody}
56+
}
57+
}
58+
59+
PHP;
60+
}
61+
62+
public function generateEmpty(string $className, string $namespace = 'App\\Migration'): string
63+
{
64+
$version = $this->extractVersion($className);
65+
66+
return <<<PHP
67+
<?php
68+
69+
namespace {$namespace};
70+
71+
use Utopia\Database\Database;
72+
use Utopia\Database\Migration\Migration;
73+
74+
class {$className} extends Migration
75+
{
76+
public function version(): string
77+
{
78+
return '{$version}';
79+
}
80+
81+
public function up(Database \$db): void
82+
{
83+
//
84+
}
85+
86+
public function down(Database \$db): void
87+
{
88+
//
89+
}
90+
}
91+
92+
PHP;
93+
}
94+
95+
private function extractVersion(string $className): string
96+
{
97+
if (\preg_match('/^V(\d+)_/', $className, $matches)) {
98+
return $matches[1];
99+
}
100+
101+
return $className;
102+
}
103+
104+
private function generateUpStatement(Change $change): ?string
105+
{
106+
$collectionId = $this->collectionId($change);
107+
108+
return match ($change->type) {
109+
ChangeType::AddAttribute => $change->attribute !== null
110+
? "\$db->createAttribute('{$collectionId}', new \\Utopia\\Database\\Attribute(key: '{$change->attribute->key}', type: \\Utopia\\Query\\Schema\\ColumnType::" . \ucfirst($change->attribute->type->value) . ", size: {$change->attribute->size}));"
111+
: null,
112+
ChangeType::DropAttribute => $change->attribute !== null
113+
? "\$db->deleteAttribute('{$collectionId}', '{$change->attribute->key}');"
114+
: null,
115+
ChangeType::AddIndex => $change->index !== null
116+
? "\$db->createIndex('{$collectionId}', new \\Utopia\\Database\\Index(key: '{$change->index->key}', type: \\Utopia\\Query\\Schema\\IndexType::" . \ucfirst($change->index->type->value) . ", attributes: " . \var_export($change->index->attributes, true) . '));'
117+
: null,
118+
ChangeType::DropIndex => $change->index !== null
119+
? "\$db->deleteIndex('{$collectionId}', '{$change->index->key}');"
120+
: null,
121+
default => null,
122+
};
123+
}
124+
125+
private function generateDownStatement(Change $change): ?string
126+
{
127+
$collectionId = $this->collectionId($change);
128+
129+
return match ($change->type) {
130+
ChangeType::AddAttribute => $change->attribute !== null
131+
? "\$db->deleteAttribute('{$collectionId}', '{$change->attribute->key}');"
132+
: null,
133+
ChangeType::DropAttribute => $change->attribute !== null
134+
? "\$db->createAttribute('{$collectionId}', new \\Utopia\\Database\\Attribute(key: '{$change->attribute->key}', type: \\Utopia\\Query\\Schema\\ColumnType::" . \ucfirst($change->attribute->type->value) . ", size: {$change->attribute->size}));"
135+
: null,
136+
ChangeType::AddIndex => $change->index !== null
137+
? "\$db->deleteIndex('{$collectionId}', '{$change->index->key}');"
138+
: null,
139+
ChangeType::DropIndex => $change->index !== null
140+
? "\$db->createIndex('{$collectionId}', new \\Utopia\\Database\\Index(key: '{$change->index->key}', type: \\Utopia\\Query\\Schema\\IndexType::" . \ucfirst($change->index->type->value) . ", attributes: " . \var_export($change->index->attributes, true) . '));'
141+
: null,
142+
default => null,
143+
};
144+
}
145+
146+
private function collectionId(Change $change): string
147+
{
148+
if ($change->collectionId === null || $change->collectionId === '') {
149+
return '{collectionId}';
150+
}
151+
152+
return $change->collectionId;
153+
}
154+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Utopia\Database\Migration;
4+
5+
use Utopia\Database\Database;
6+
7+
abstract class Migration
8+
{
9+
abstract public function version(): string;
10+
11+
abstract public function up(Database $db): void;
12+
13+
abstract public function down(Database $db): void;
14+
15+
public function name(): string
16+
{
17+
return static::class;
18+
}
19+
}

0 commit comments

Comments
 (0)