From b31c474b4657249c380ec154f2bb109f5e2f7612 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 27 Aug 2026 13:20:51 +1200 Subject: [PATCH] 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 --- bin/cli.php | 1 + bin/tasks/migrate.php | 184 ++++++++ phpstan.neon | 2 + src/Database/Migration/Generator.php | 154 +++++++ src/Database/Migration/Migration.php | 19 + src/Database/Migration/Runner.php | 133 ++++++ .../Migration/Strategy/ExpandContract.php | 61 +++ .../Migration/Strategy/OnlineSchemaChange.php | 21 + src/Database/Migration/Tracker.php | 138 ++++++ src/Database/Schema/Change.php | 18 + src/Database/Schema/ChangeType.php | 16 + src/Database/Schema/Diff.php | 92 ++++ src/Database/Schema/DiffResult.php | 91 ++++ src/Database/Schema/Introspector.php | 40 ++ tests/unit/CLITasksTest.php | 148 +++++++ .../unit/Migration/OnlineSchemaChangeTest.php | 55 +++ tests/unit/Migration/RunnerTest.php | 408 ++++++++++++++++++ tests/unit/Schema/DiffTest.php | 247 +++++++++++ 18 files changed, 1828 insertions(+) create mode 100644 bin/tasks/migrate.php create mode 100644 src/Database/Migration/Generator.php create mode 100644 src/Database/Migration/Migration.php create mode 100644 src/Database/Migration/Runner.php create mode 100644 src/Database/Migration/Strategy/ExpandContract.php create mode 100644 src/Database/Migration/Strategy/OnlineSchemaChange.php create mode 100644 src/Database/Migration/Tracker.php create mode 100644 src/Database/Schema/Change.php create mode 100644 src/Database/Schema/ChangeType.php create mode 100644 src/Database/Schema/Diff.php create mode 100644 src/Database/Schema/DiffResult.php create mode 100644 src/Database/Schema/Introspector.php create mode 100644 tests/unit/Migration/OnlineSchemaChangeTest.php create mode 100644 tests/unit/Migration/RunnerTest.php create mode 100644 tests/unit/Schema/DiffTest.php diff --git a/bin/cli.php b/bin/cli.php index 7054c3e641..da4b50fda2 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -69,6 +69,7 @@ include 'tasks/query.php'; include 'tasks/relationships.php'; include 'tasks/operators.php'; +include 'tasks/migrate.php'; $cli ->error() diff --git a/bin/tasks/migrate.php b/bin/tasks/migrate.php new file mode 100644 index 0000000000..7e4275698c --- /dev/null +++ b/bin/tasks/migrate.php @@ -0,0 +1,184 @@ +task('migrate') + ->desc('Run pending database migrations') + ->param('path', 'migrations', new Text(0), 'Path to migration files', true) + ->param('adapter', '', new Text(0), 'Database adapter') + ->param('name', '', new Text(0), 'Database name') + ->param('namespace', '_ns', new Text(0), 'Database namespace', true) + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->inject('database') + ->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) { + $migrations = loadMigrations($path); + + if ($migrations === []) { + Console::warning('No migration files found in: ' . $path); + + return; + } + + Console::info('Running migrations...'); + + $db = $database($adapter, $name, $namespace, $sharedTables); + if (! $db instanceof Database) { + throw new \RuntimeException('The database resource must return a Database instance.'); + } + $runner = new Runner($db); + $count = $runner->migrate($migrations); + + Console::success("Ran {$count} migration(s)."); + }); + +$cli + ->task('migrate:rollback') + ->desc('Rollback the last batch of migrations') + ->param('path', 'migrations', new Text(0), 'Path to migration files', true) + ->param('steps', 1, new Integer(true), 'Number of batches to rollback', true) + ->param('adapter', '', new Text(0), 'Database adapter') + ->param('name', '', new Text(0), 'Database name') + ->param('namespace', '_ns', new Text(0), 'Database namespace', true) + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->inject('database') + ->action(function (string $path, int $steps, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) { + $migrations = loadMigrations($path); + $db = $database($adapter, $name, $namespace, $sharedTables); + if (! $db instanceof Database) { + throw new \RuntimeException('The database resource must return a Database instance.'); + } + $runner = new Runner($db); + $count = $runner->rollback($migrations, $steps); + + Console::success("Rolled back {$count} migration(s)."); + }); + +$cli + ->task('migrate:status') + ->desc('Show the status of all migrations') + ->param('path', 'migrations', new Text(0), 'Path to migration files', true) + ->param('adapter', '', new Text(0), 'Database adapter') + ->param('name', '', new Text(0), 'Database name') + ->param('namespace', '_ns', new Text(0), 'Database namespace', true) + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->inject('database') + ->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) { + $migrations = loadMigrations($path); + $db = $database($adapter, $name, $namespace, $sharedTables); + if (! $db instanceof Database) { + throw new \RuntimeException('The database resource must return a Database instance.'); + } + $runner = new Runner($db); + $status = $runner->status($migrations); + + Console::info(\str_pad('Version', 20) . \str_pad('Name', 40) . 'Applied'); + Console::info(\str_repeat('-', 70)); + + foreach ($status as $entry) { + $applied = $entry['applied'] ? 'Yes' : 'No'; + Console::log(\str_pad($entry['version'], 20) . \str_pad($entry['name'], 40) . $applied); + } + }); + +$cli + ->task('migrate:fresh') + ->desc('Drop all collections and re-run all migrations') + ->param('path', 'migrations', new Text(0), 'Path to migration files', true) + ->param('adapter', '', new Text(0), 'Database adapter') + ->param('name', '', new Text(0), 'Database name') + ->param('namespace', '_ns', new Text(0), 'Database namespace', true) + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->inject('database') + ->action(function (string $path, string $adapter, string $name, string $namespace, bool $sharedTables, callable $database) { + $migrations = loadMigrations($path); + $db = $database($adapter, $name, $namespace, $sharedTables); + if (! $db instanceof Database) { + throw new \RuntimeException('The database resource must return a Database instance.'); + } + $runner = new Runner($db); + + Console::warning('Dropping all collections and re-migrating...'); + $count = $runner->fresh($migrations); + + Console::success("Fresh migration complete. Ran {$count} migration(s)."); + }); + +$cli + ->task('migrate:generate') + ->desc('Generate an empty migration file') + ->param('name', '', new Text(0), 'Migration name (e.g. add_users_table)') + ->param('path', 'migrations', new Text(0), 'Output directory', true) + ->action(function (string $name, string $path) { + $timestamp = \date('YmdHis'); + $className = 'V' . $timestamp . '_' . \str_replace(' ', '', \ucwords(\str_replace('_', ' ', $name))); + + $generator = new Generator(); + $content = $generator->generateEmpty($className); + + if (! \is_dir($path)) { + \mkdir($path, 0755, true); + } + + $filePath = $path . '/' . $className . '.php'; + \file_put_contents($filePath, $content); + + Console::success("Created migration: {$filePath}"); + }); + +/** + * @return array + */ +function loadMigrations(string $path): array +{ + if (! \is_dir($path)) { + return []; + } + + $migrations = []; + $files = \glob($path . '/*.php'); + + if ($files === false) { + return []; + } + + foreach ($files as $file) { + $before = \get_declared_classes(); + + require_once $file; + + // migrate:generate writes the class under a namespace, so the file name + // is not the class name and looking it up that way finds nothing -- + // silently, leaving the run reporting success having skipped it. Take + // whatever the file declared instead of guessing at it. + foreach (\array_diff(\get_declared_classes(), $before) as $className) { + if (\is_subclass_of($className, Migration::class)) { + $migrations[] = new $className(); + } + } + } + + return $migrations; +} diff --git a/phpstan.neon b/phpstan.neon index c8e20bf4af..cc31418d21 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,8 @@ parameters: - src - tests scanFiles: + # Declares loadMigrations(), which tests/unit/CLITasksTest.php calls. + - bin/tasks/migrate.php - stubs/Swoole/Database/DetectsLostConnections.stub.php - stubs/Swoole/Database/PDOProxy.stub.php - stubs/Swoole/Database/PDOStatementProxy.stub.php diff --git a/src/Database/Migration/Generator.php b/src/Database/Migration/Generator.php new file mode 100644 index 0000000000..47426644fb --- /dev/null +++ b/src/Database/Migration/Generator.php @@ -0,0 +1,154 @@ +extractVersion($className); + $upLines = []; + $downLines = []; + + foreach ($diff->changes as $change) { + $up = $this->generateUpStatement($change); + $down = $this->generateDownStatement($change); + + if ($up !== null) { + $upLines[] = " {$up}"; + } + + if ($down !== null) { + $downLines[] = " {$down}"; + } + } + + $upBody = $upLines !== [] ? \implode("\n", $upLines) : ' // No changes'; + $downBody = $downLines !== [] ? \implode("\n", \array_reverse($downLines)) : ' // No changes'; + + return <<extractVersion($className); + + return <<collectionId($change); + + return match ($change->type) { + ChangeType::AddAttribute => $change->attribute !== null + ? "\$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}));" + : null, + ChangeType::DropAttribute => $change->attribute !== null + ? "\$db->deleteAttribute('{$collectionId}', '{$change->attribute->key}');" + : null, + ChangeType::AddIndex => $change->index !== null + ? "\$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) . '));' + : null, + ChangeType::DropIndex => $change->index !== null + ? "\$db->deleteIndex('{$collectionId}', '{$change->index->key}');" + : null, + default => null, + }; + } + + private function generateDownStatement(Change $change): ?string + { + $collectionId = $this->collectionId($change); + + return match ($change->type) { + ChangeType::AddAttribute => $change->attribute !== null + ? "\$db->deleteAttribute('{$collectionId}', '{$change->attribute->key}');" + : null, + ChangeType::DropAttribute => $change->attribute !== null + ? "\$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}));" + : null, + ChangeType::AddIndex => $change->index !== null + ? "\$db->deleteIndex('{$collectionId}', '{$change->index->key}');" + : null, + ChangeType::DropIndex => $change->index !== null + ? "\$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) . '));' + : null, + default => null, + }; + } + + private function collectionId(Change $change): string + { + if ($change->collectionId === null || $change->collectionId === '') { + return '{collectionId}'; + } + + return $change->collectionId; + } +} diff --git a/src/Database/Migration/Migration.php b/src/Database/Migration/Migration.php new file mode 100644 index 0000000000..b3ae3332da --- /dev/null +++ b/src/Database/Migration/Migration.php @@ -0,0 +1,19 @@ +db = $db; + $this->tracker = $tracker ?? new Tracker($db); + } + + /** + * @param array $migrations + */ + public function migrate(array $migrations): int + { + $this->tracker->setup(); + $executed = $this->tracker->getAppliedVersions(); + $batch = $this->tracker->getLastBatch() + 1; + + $pending = \array_filter( + $migrations, + fn (Migration $m) => ! \in_array($m->version(), $executed, true) + ); + + \usort($pending, fn (Migration $a, Migration $b) => \strcmp($a->version(), $b->version())); + + $count = 0; + + foreach ($pending as $migration) { + $this->db->withTransaction(function () use ($migration, $batch): void { + $migration->up($this->db); + $this->tracker->markApplied($migration->version(), $migration->name(), $batch); + }); + $count++; + } + + return $count; + } + + /** + * @param array $migrations + */ + public function rollback(array $migrations, int $steps = 1): int + { + $this->tracker->setup(); + $lastBatch = $this->tracker->getLastBatch(); + $count = 0; + + $migrationsByVersion = []; + foreach ($migrations as $migration) { + $migrationsByVersion[$migration->version()] = $migration; + } + + for ($batch = $lastBatch; $batch > $lastBatch - $steps && $batch > 0; $batch--) { + $applied = $this->tracker->getByBatch($batch); + + foreach ($applied as $doc) { + $version = $doc->getAttribute('version', ''); + if (! \is_string($version) || $version === '') { + continue; + } + + if (isset($migrationsByVersion[$version])) { + $this->db->withTransaction(function () use ($migrationsByVersion, $version): void { + $migrationsByVersion[$version]->down($this->db); + $this->tracker->markRolledBack($version); + }); + $count++; + } + } + } + + return $count; + } + + /** + * @param array $migrations + * @return array + */ + public function status(array $migrations): array + { + $this->tracker->setup(); + $executed = $this->tracker->getAppliedVersions(); + $status = []; + + \usort($migrations, fn (Migration $a, Migration $b) => \strcmp($a->version(), $b->version())); + + foreach ($migrations as $migration) { + $status[] = [ + 'version' => $migration->version(), + 'name' => $migration->name(), + 'applied' => \in_array($migration->version(), $executed, true), + ]; + } + + return $status; + } + + /** + * @param array $migrations + */ + public function fresh(array $migrations): int + { + $collections = $this->db->listCollections(); + + foreach ($collections as $collection) { + $id = $collection->getId(); + if ($id !== '_metadata' && $id !== '') { + try { + $this->db->deleteCollection($id); + } catch (\Throwable) { + } + } + } + + $this->tracker->reset(); + + return $this->migrate($migrations); + } + + public function getTracker(): Tracker + { + return $this->tracker; + } +} diff --git a/src/Database/Migration/Strategy/ExpandContract.php b/src/Database/Migration/Strategy/ExpandContract.php new file mode 100644 index 0000000000..ac47707a89 --- /dev/null +++ b/src/Database/Migration/Strategy/ExpandContract.php @@ -0,0 +1,61 @@ +createAttribute($collection, $newAttribute); + } + + public function migrate(Database $db, string $collection, string $oldKey, string $newKey, callable $transform, int $batchSize = 100): int + { + $count = 0; + $lastDocument = null; + + while (true) { + $queries = [Query::limit($batchSize)]; + + if ($lastDocument !== null) { + $queries[] = Query::cursorAfter($lastDocument); + } + + $documents = $db->find($collection, $queries); + + if ($documents === []) { + break; + } + + foreach ($documents as $doc) { + $oldValue = $doc->getAttribute($oldKey); + $newValue = $transform($oldValue); + + $db->updateDocument($collection, $doc->getId(), new Document([ + Document::ID => $doc->getId(), + $newKey => $newValue, + ])); + + $count++; + } + + $lastDocument = \end($documents); + + if (\count($documents) < $batchSize) { + break; + } + } + + return $count; + } + + public function contract(Database $db, string $collection, string $oldKey): void + { + $db->deleteAttribute($collection, $oldKey); + } +} diff --git a/src/Database/Migration/Strategy/OnlineSchemaChange.php b/src/Database/Migration/Strategy/OnlineSchemaChange.php new file mode 100644 index 0000000000..a1cf133d61 --- /dev/null +++ b/src/Database/Migration/Strategy/OnlineSchemaChange.php @@ -0,0 +1,21 @@ +getAdapter(); + $hadLocks = $adapter->getAlterLocks(); + $adapter->enableAlterLocks(false); + + try { + $changes($db, $collection); + } finally { + $adapter->enableAlterLocks($hadLocks); + } + } +} diff --git a/src/Database/Migration/Tracker.php b/src/Database/Migration/Tracker.php new file mode 100644 index 0000000000..14b152b729 --- /dev/null +++ b/src/Database/Migration/Tracker.php @@ -0,0 +1,138 @@ +db = $db; + } + + public function reset(): void + { + $this->initialized = false; + } + + public function setup(): void + { + if ($this->initialized) { + return; + } + + if ($this->db->exists($this->db->getAdapter()->getDatabase(), self::COLLECTION)) { + $this->initialized = true; + + return; + } + + $this->db->createCollection(new Collection(id: self::COLLECTION, attributes: [ + Attribute::string(key: 'version', required: true), + Attribute::string(key: 'name', required: true), + Attribute::integer(key: 'batch', required: true), + Attribute::datetime(key: 'appliedAt', filters: ['datetime']), + ])); + + $this->initialized = true; + } + + /** + * @return array + */ + public function getApplied(): array + { + $this->setup(); + + return $this->db->find(self::COLLECTION, [ + Query::orderAsc('version'), + ]); + } + + /** + * @return array + */ + public function getAppliedVersions(): array + { + return \array_values(\array_filter( + \array_map( + static function (Document $doc): ?string { + $version = $doc->getAttribute('version', ''); + + return \is_string($version) ? $version : null; + }, + $this->getApplied() + ), + )); + } + + public function markApplied(string $version, string $name, int $batch): void + { + $this->setup(); + + $this->db->createDocument(self::COLLECTION, new Document([ + Document::ID => ID::unique(), + 'version' => $version, + 'name' => $name, + 'batch' => $batch, + 'appliedAt' => \date('Y-m-d H:i:s'), + ])); + } + + public function markRolledBack(string $version): void + { + $this->setup(); + + $docs = $this->db->find(self::COLLECTION, [ + Query::equal('version', [$version]), + Query::limit(1), + ]); + + if ($docs !== []) { + $this->db->deleteDocument(self::COLLECTION, $docs[0]->getId()); + } + } + + public function getLastBatch(): int + { + $this->setup(); + + $docs = $this->db->find(self::COLLECTION, [ + Query::orderDesc('batch'), + Query::limit(1), + ]); + + if ($docs === []) { + return 0; + } + + $batch = $docs[0]->getAttribute('batch', 0); + + return \is_numeric($batch) ? (int) $batch : 0; + } + + /** + * @return array + */ + public function getByBatch(int $batch): array + { + $this->setup(); + + return $this->db->find(self::COLLECTION, [ + Query::equal('batch', [$batch]), + Query::orderDesc('version'), + ]); + } +} diff --git a/src/Database/Schema/Change.php b/src/Database/Schema/Change.php new file mode 100644 index 0000000000..a7c03ecb99 --- /dev/null +++ b/src/Database/Schema/Change.php @@ -0,0 +1,18 @@ +getId() !== '' ? $target->getId() : $source->getId(); + + $sourceAttrs = []; + foreach ($source->attributes as $attr) { + $sourceAttrs[$attr->key] = $attr; + } + + $targetAttrs = []; + foreach ($target->attributes as $attr) { + $targetAttrs[$attr->key] = $attr; + } + + foreach ($targetAttrs as $key => $attr) { + if (! isset($sourceAttrs[$key])) { + $changes[] = new Change(ChangeType::AddAttribute, attribute: $attr, collectionId: $collectionId); + } elseif ($this->attributeDiffers($sourceAttrs[$key], $attr)) { + $changes[] = new Change( + ChangeType::ModifyAttribute, + attribute: $attr, + previousAttribute: $sourceAttrs[$key], + collectionId: $collectionId, + ); + } + } + + foreach ($sourceAttrs as $key => $attr) { + if (! isset($targetAttrs[$key])) { + $changes[] = new Change(ChangeType::DropAttribute, attribute: $attr, collectionId: $collectionId); + } + } + + $sourceIndexes = []; + foreach ($source->indexes as $idx) { + $sourceIndexes[$idx->key] = $idx; + } + + $targetIndexes = []; + foreach ($target->indexes as $idx) { + $targetIndexes[$idx->key] = $idx; + } + + foreach ($targetIndexes as $key => $idx) { + if (! isset($sourceIndexes[$key])) { + $changes[] = new Change(ChangeType::AddIndex, index: $idx, collectionId: $collectionId); + } elseif ($this->indexDiffers($sourceIndexes[$key], $idx)) { + $changes[] = new Change(ChangeType::DropIndex, index: $sourceIndexes[$key], collectionId: $collectionId); + $changes[] = new Change(ChangeType::AddIndex, index: $idx, collectionId: $collectionId); + } + } + + foreach ($sourceIndexes as $key => $idx) { + if (! isset($targetIndexes[$key])) { + $changes[] = new Change(ChangeType::DropIndex, index: $idx, collectionId: $collectionId); + } + } + + return new DiffResult($changes); + } + + private function attributeDiffers(Attribute $source, Attribute $target): bool + { + return $source->type !== $target->type + || $source->size !== $target->size + || $source->required !== $target->required + || $source->signed !== $target->signed + || $source->array !== $target->array + || $source->format !== $target->format + || $source->default !== $target->default; + } + + private function indexDiffers(Index $source, Index $target): bool + { + return $source->type !== $target->type + || $source->attributes !== $target->attributes + || $source->lengths !== $target->lengths + || $source->ttl !== $target->ttl + || $source->orders != $target->orders; + } +} diff --git a/src/Database/Schema/DiffResult.php b/src/Database/Schema/DiffResult.php new file mode 100644 index 0000000000..1a99305eff --- /dev/null +++ b/src/Database/Schema/DiffResult.php @@ -0,0 +1,91 @@ + $changes + */ + public function __construct( + public readonly array $changes, + ) { + } + + public function hasChanges(): bool + { + return $this->changes !== []; + } + + public function apply(Database $db, string $collectionId): void + { + foreach ($this->changes as $change) { + match ($change->type) { + ChangeType::AddAttribute => $change->attribute !== null + ? $db->createAttribute($collectionId, $change->attribute) + : null, + ChangeType::DropAttribute => $change->attribute !== null + ? $db->deleteAttribute($collectionId, $change->attribute->key) + : null, + ChangeType::ModifyAttribute => $change->attribute !== null + ? $db->updateAttribute( + $collectionId, + $change->attribute->key, + type: $change->attribute->type, + size: $change->attribute->size, + required: $change->attribute->required, + default: $change->attribute->default, + signed: $change->attribute->signed, + array: $change->attribute->array, + format: $change->attribute->format, + formatOptions: $change->attribute->formatOptions, + filters: $change->attribute->filters, + ) + : null, + ChangeType::AddIndex => $change->index !== null + ? $db->createIndex($collectionId, $change->index) + : null, + ChangeType::DropIndex => $change->index !== null + ? $db->deleteIndex($collectionId, $change->index->key) + : null, + default => null, + }; + } + } + + /** + * @return array + */ + public function getAdditions(): array + { + return \array_filter($this->changes, fn (Change $c) => \in_array($c->type, [ + ChangeType::AddAttribute, + ChangeType::AddIndex, + ChangeType::AddRelationship, + ChangeType::CreateCollection, + ], true)); + } + + /** + * @return array + */ + public function getRemovals(): array + { + return \array_filter($this->changes, fn (Change $c) => \in_array($c->type, [ + ChangeType::DropAttribute, + ChangeType::DropIndex, + ChangeType::DropRelationship, + ChangeType::DropCollection, + ], true)); + } + + /** + * @return array + */ + public function getModifications(): array + { + return \array_filter($this->changes, fn (Change $c) => $c->type === ChangeType::ModifyAttribute); + } +} diff --git a/src/Database/Schema/Introspector.php b/src/Database/Schema/Introspector.php new file mode 100644 index 0000000000..34dbd1e85f --- /dev/null +++ b/src/Database/Schema/Introspector.php @@ -0,0 +1,40 @@ +db->getCollection($collectionId); + + if ($collectionDoc->isEmpty()) { + throw new \RuntimeException("Collection '{$collectionId}' not found"); + } + + return $collectionDoc; + } + + /** + * @return array + */ + public function introspectDatabase(): array + { + $collections = $this->db->listCollections(); + $result = []; + + foreach ($collections as $doc) { + $result[] = $doc; + } + + return $result; + } +} diff --git a/tests/unit/CLITasksTest.php b/tests/unit/CLITasksTest.php index 274c5faa85..a57ffe6ae5 100644 --- a/tests/unit/CLITasksTest.php +++ b/tests/unit/CLITasksTest.php @@ -3,9 +3,87 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; +use Utopia\Cache\Adapter\None as NoCache; +use Utopia\Cache\Cache; +use Utopia\CLI\CLI; +use Utopia\Database\Adapter\Memory; +use Utopia\Database\Database; +use Utopia\Database\Migration\Generator; +use Utopia\DI\Dependency; final class CLITasksTest extends TestCase { + public function testMigrationCommandsUseInjectedDatabaseFactory(): void + { + $path = \sys_get_temp_dir().'/database-migrations-'.\bin2hex(\random_bytes(8)); + \mkdir($path); + $cli = new CLI(args: [ + 'bin/cli', + 'migrate:status', + '--path='.$path, + '--adapter=memory', + '--name=testing', + '--namespace=cli', + '--sharedTables=0', + ]); + $GLOBALS['cli'] = $cli; + include __DIR__.'/../../bin/tasks/migrate.php'; + unset($GLOBALS['cli']); + + $this->assertSame( + ['migrate', 'migrate:rollback', 'migrate:status', 'migrate:fresh', 'migrate:generate'], + \array_keys($cli->getTasks()), + ); + + foreach (['migrate', 'migrate:rollback', 'migrate:status', 'migrate:fresh'] as $task) { + $this->assertSame(['database'], $cli->getTasks()[$task]->getDependencies()); + } + $this->assertSame([], $cli->getTasks()['migrate:generate']->getDependencies()); + + $received = []; + $database = null; + $resource = new Dependency(); + $resource + ->setName('database') + ->setCallback(function () use (&$received, &$database): callable { + return function (string $adapter, string $name, string $namespace, bool $sharedTables) use (&$received, &$database): Database { + $received = \func_get_args(); + $database = (new Database(new Memory(), new Cache(new NoCache()))) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + $database->create(); + + return $database; + }; + }); + $cli->setResource($resource); + $caught = null; + $cli + ->error() + ->inject('error') + ->action(function (\Throwable $error) use (&$caught): void { + $caught = $error; + }); + $cli->run(); + + $this->assertNull($caught); + $this->assertSame(['memory', 'testing', 'cli', false], $received); + $this->assertInstanceOf(Database::class, $database); + $this->assertTrue($database->exists('testing', '_migrations')); + + \rmdir($path); + } + + public function testMainCliIncludesMigrationCommands(): void + { + $source = \file_get_contents(__DIR__.'/../../bin/cli.php'); + $this->assertIsString($source); + $this->assertStringContainsString("include 'tasks/migrate.php';", $source); + $this->assertStringContainsString('if (! $database->exists()) {', $source); + $this->assertStringContainsString('$database->create();', $source); + } + public function testOperatorSetupUsesDatabaseAuthorization(): void { $source = \file_get_contents(__DIR__.'/../../bin/tasks/operators.php'); @@ -16,4 +94,74 @@ public function testOperatorSetupUsesDatabaseAuthorization(): void ); $this->assertStringNotContainsString('$authorization->', $source); } + + /** + * migrate:generate writes the class under a namespace, so the runner has to + * find it by more than the filename. Looking up the bare filename finds + * nothing, and nothing complains -- the migration is skipped and the run + * reports success having done none of it. + */ + public function testAGeneratedMigrationIsFoundByTheRunner(): void + { + $path = \sys_get_temp_dir().'/database-migrations-'.\bin2hex(\random_bytes(8)); + \mkdir($path); + + $className = 'V20260101000000_AddWidgets'; + \file_put_contents($path.'/'.$className.'.php', (new Generator())->generateEmpty($className)); + + $this->includeMigrationTasks(); + + $migrations = \loadMigrations($path); + + $this->assertCount(1, $migrations, 'A migration written by migrate:generate has to be picked up by the runner'); + $this->assertSame('20260101000000', $migrations[0]->version()); + } + + public function testAMigrationDeclaredWithoutANamespaceIsStillFound(): void + { + $path = \sys_get_temp_dir().'/database-migrations-'.\bin2hex(\random_bytes(8)); + \mkdir($path); + + $className = 'V20260101000001_AddGadgets'; + \file_put_contents($path.'/'.$className.'.php', <<includeMigrationTasks(); + + $migrations = \loadMigrations($path); + + $this->assertCount(1, $migrations); + $this->assertSame('20260101000001', $migrations[0]->version()); + } + + private function includeMigrationTasks(): void + { + if (\function_exists('loadMigrations')) { + return; + } + + $GLOBALS['cli'] = new CLI(args: ['bin/cli', 'migrate:status']); + include __DIR__.'/../../bin/tasks/migrate.php'; + unset($GLOBALS['cli']); + } } diff --git a/tests/unit/Migration/OnlineSchemaChangeTest.php b/tests/unit/Migration/OnlineSchemaChangeTest.php new file mode 100644 index 0000000000..979e861c38 --- /dev/null +++ b/tests/unit/Migration/OnlineSchemaChangeTest.php @@ -0,0 +1,55 @@ +createMock(Adapter::class); + $adapter->method('getAlterLocks')->willReturnCallback(static fn (): bool => $locks[0]); + $adapter->method('enableAlterLocks')->willReturnCallback(function (bool $enable) use (&$locks, $adapter): Adapter { + $locks[] = $enable; + $locks[0] = $enable; + + return $adapter; + }); + + $db = $this->createMock(Database::class); + $db->method('getAdapter')->willReturn($adapter); + + $strategy = new OnlineSchemaChange(); + $strategy->alter($db, 'users', function () use (&$locks): void { + $this->assertFalse($locks[0]); + }); + + $this->assertSame([true, false, true], $locks); + } + + public function testAlterLeavesLocksDisabledWhenTheyStartedDisabled(): void + { + $locks = [false]; + $adapter = $this->createMock(Adapter::class); + $adapter->method('getAlterLocks')->willReturnCallback(static fn (): bool => $locks[0]); + $adapter->method('enableAlterLocks')->willReturnCallback(function (bool $enable) use (&$locks, $adapter): Adapter { + $locks[0] = $enable; + + return $adapter; + }); + + $db = $this->createMock(Database::class); + $db->method('getAdapter')->willReturn($adapter); + + $strategy = new OnlineSchemaChange(); + $strategy->alter($db, 'users', function (): void { + }); + + $this->assertFalse($locks[0]); + } +} diff --git a/tests/unit/Migration/RunnerTest.php b/tests/unit/Migration/RunnerTest.php new file mode 100644 index 0000000000..cf3da1114f --- /dev/null +++ b/tests/unit/Migration/RunnerTest.php @@ -0,0 +1,408 @@ +db = self::createStub(Database::class); + } + + private function createMigration(string $version, ?callable $up = null, ?callable $down = null): Migration + { + return new class ($version, $up, $down) extends Migration { + private string $ver; + + /** @var callable|null */ + private $upFn; + + /** @var callable|null */ + private $downFn; + + public function __construct(string $ver, ?callable $upFn = null, ?callable $downFn = null) + { + $this->ver = $ver; + $this->upFn = $upFn; + $this->downFn = $downFn; + } + + public function version(): string + { + return $this->ver; + } + + public function up(Database $db): void + { + if ($this->upFn) { + ($this->upFn)($db); + } + } + + public function down(Database $db): void + { + if ($this->downFn) { + ($this->downFn)($db); + } + } + }; + } + + /** + * @param array $appliedVersions + * @param array> $batchDocs + */ + private function createTrackerMock(array $appliedVersions = [], int $lastBatch = 0, array $batchDocs = []): Tracker + { + $tracker = self::createStub(Tracker::class); + $tracker->method('setup'); + $tracker->method('getAppliedVersions')->willReturn($appliedVersions); + $tracker->method('getLastBatch')->willReturn($lastBatch); + $tracker->method('getByBatch')->willReturnCallback(function (int $batch) use ($batchDocs) { + return $batchDocs[$batch] ?? []; + }); + $tracker->method('markApplied'); + $tracker->method('markRolledBack'); + + return $tracker; + } + + public function testMigrateRunsPendingMigrationsInVersionOrder(): void + { + $order = []; + + $m1 = $this->createMigration('002', function () use (&$order) { + $order[] = '002'; + }); + $m2 = $this->createMigration('001', function () use (&$order) { + $order[] = '001'; + }); + + $tracker = $this->createTrackerMock(); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $runner->migrate([$m1, $m2]); + + $this->assertEquals(['001', '002'], $order); + } + + public function testMigrateSkipsAlreadyAppliedMigrations(): void + { + $executed = []; + + $m1 = $this->createMigration('001', function () use (&$executed) { + $executed[] = '001'; + }); + $m2 = $this->createMigration('002', function () use (&$executed) { + $executed[] = '002'; + }); + + $tracker = $this->createTrackerMock(appliedVersions: ['001']); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $runner->migrate([$m1, $m2]); + + $this->assertEquals(['002'], $executed); + } + + public function testMigrateReturnsCountOfExecutedMigrations(): void + { + $m1 = $this->createMigration('001'); + $m2 = $this->createMigration('002'); + + $tracker = $this->createTrackerMock(); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $count = $runner->migrate([$m1, $m2]); + + $this->assertEquals(2, $count); + } + + public function testMigrateWithNoPendingReturnsZero(): void + { + $m1 = $this->createMigration('001'); + + $tracker = $this->createTrackerMock(appliedVersions: ['001']); + + $runner = new Runner($this->db, $tracker); + $count = $runner->migrate([$m1]); + + $this->assertEquals(0, $count); + } + + public function testRollbackCallsDownInReverseOrder(): void + { + $order = []; + + $m1 = $this->createMigration('001', null, function () use (&$order) { + $order[] = '001'; + }); + $m2 = $this->createMigration('002', null, function () use (&$order) { + $order[] = '002'; + }); + + $batchDocs = [ + 1 => [ + new Document(['version' => '002']), + new Document(['version' => '001']), + ], + ]; + + $tracker = $this->createTrackerMock(lastBatch: 1, batchDocs: $batchDocs); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $runner->rollback([$m1, $m2], 1); + + $this->assertEquals(['002', '001'], $order); + } + + public function testRollbackBySteps(): void + { + $order = []; + + $m1 = $this->createMigration('001', null, function () use (&$order) { + $order[] = '001'; + }); + $m2 = $this->createMigration('002', null, function () use (&$order) { + $order[] = '002'; + }); + $m3 = $this->createMigration('003', null, function () use (&$order) { + $order[] = '003'; + }); + + $batchDocs = [ + 1 => [new Document(['version' => '001'])], + 2 => [new Document(['version' => '002']), new Document(['version' => '003'])], + ]; + + $tracker = $this->createTrackerMock(lastBatch: 2, batchDocs: $batchDocs); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $count = $runner->rollback([$m1, $m2, $m3], 1); + + $this->assertEquals(2, $count); + $this->assertEquals(['002', '003'], $order); + } + + public function testRollbackReturnsCount(): void + { + $m1 = $this->createMigration('001', null, function () { + }); + + $batchDocs = [ + 1 => [new Document(['version' => '001'])], + ]; + + $tracker = $this->createTrackerMock(lastBatch: 1, batchDocs: $batchDocs); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $count = $runner->rollback([$m1], 1); + + $this->assertEquals(1, $count); + } + + public function testStatusReturnsAllMigrationsWithAppliedFlag(): void + { + $m1 = $this->createMigration('001'); + $m2 = $this->createMigration('002'); + + $tracker = $this->createTrackerMock(appliedVersions: ['001']); + + $runner = new Runner($this->db, $tracker); + $status = $runner->status([$m1, $m2]); + + $this->assertCount(2, $status); + $this->assertTrue($status[0]['applied']); + $this->assertFalse($status[1]['applied']); + } + + public function testStatusReturnsSortedByVersion(): void + { + $m1 = $this->createMigration('003'); + $m2 = $this->createMigration('001'); + + $tracker = $this->createTrackerMock(); + + $runner = new Runner($this->db, $tracker); + $status = $runner->status([$m1, $m2]); + + $this->assertEquals('001', $status[0]['version']); + $this->assertEquals('003', $status[1]['version']); + } + + public function testGetTrackerReturnsTracker(): void + { + $tracker = $this->createTrackerMock(); + $runner = new Runner($this->db, $tracker); + $this->assertSame($tracker, $runner->getTracker()); + } + + public function testGeneratorGenerateEmptyProducesValidPHP(): void + { + $generator = new Generator(); + $output = $generator->generateEmpty('V001_CreateUsers'); + + $this->assertStringContainsString('class V001_CreateUsers extends Migration', $output); + $this->assertStringContainsString("return '001'", $output); + $this->assertStringContainsString('public function up(Database $db): void', $output); + $this->assertStringContainsString('public function down(Database $db): void', $output); + } + + public function testGeneratorGenerateWithDiffResultIncludesUpDownMethods(): void + { + $diff = new DiffResult([ + new Change( + type: ChangeType::AddAttribute, + attribute: Attribute::string(key: 'email'), + ), + ]); + + $generator = new Generator(); + $output = $generator->generate($diff, 'V002_AddEmail'); + + $this->assertStringContainsString('class V002_AddEmail extends Migration', $output); + $this->assertStringContainsString("return '002'", $output); + $this->assertStringContainsString('email', $output); + } + + public function testGeneratorExtractVersionFromV001Prefix(): void + { + $generator = new Generator(); + $output = $generator->generateEmpty('V042_SomeChange'); + $this->assertStringContainsString("return '042'", $output); + } + + public function testGeneratorFallsBackToClassName(): void + { + $generator = new Generator(); + $output = $generator->generateEmpty('CreateUsersTable'); + $this->assertStringContainsString("return 'CreateUsersTable'", $output); + } + + public function testMigrationAbstractClassNameReturnsClassName(): void + { + $migration = $this->createMigration('001'); + $this->assertNotEmpty($migration->name()); + } + + public function testMigrateWithEmptyArrayReturnsZero(): void + { + $tracker = $this->createTrackerMock(); + $runner = new Runner($this->db, $tracker); + $count = $runner->migrate([]); + $this->assertEquals(0, $count); + } + + public function testRollbackWithNoMigrationsInBatch(): void + { + $tracker = $this->createTrackerMock(lastBatch: 1, batchDocs: [1 => []]); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $count = $runner->rollback([], 1); + $this->assertEquals(0, $count); + } + + public function testGeneratorGenerateWithDropAttribute(): void + { + $diff = new DiffResult([ + new Change( + type: ChangeType::DropAttribute, + attribute: Attribute::string(key: 'legacy', size: 100), + ), + ]); + + $generator = new Generator(); + $output = $generator->generate($diff, 'V003_DropLegacy'); + + $this->assertStringContainsString('legacy', $output); + $this->assertStringContainsString('deleteAttribute', $output); + } + + public function testGeneratorGenerateWithAddIndex(): void + { + $diff = new DiffResult([ + new Change( + type: ChangeType::AddIndex, + index: Index::index(key: 'idx_email', attributes: ['email']), + ), + ]); + + $generator = new Generator(); + $output = $generator->generate($diff, 'V004_AddIndex'); + + $this->assertStringContainsString('idx_email', $output); + $this->assertStringContainsString('createIndex', $output); + } + + public function testGeneratorRendersCollectionIdAndReversesDownStatements(): void + { + $diff = new DiffResult([ + new Change( + type: ChangeType::AddAttribute, + attribute: Attribute::string(key: 'email'), + collectionId: 'users', + ), + new Change( + type: ChangeType::AddIndex, + index: Index::index(key: 'idx_email', attributes: ['email']), + collectionId: 'users', + ), + ]); + + $output = (new Generator())->generate($diff, 'V005_AddEmailIndex'); + + $this->assertStringContainsString("createAttribute('users'", $output); + $this->assertStringNotContainsString('{collectionId}', $output); + + $downStart = \strpos($output, 'function down(Database $db): void'); + $this->assertNotFalse($downStart); + $down = \substr($output, $downStart); + $deleteIndexAt = \strpos($down, 'deleteIndex'); + $deleteAttributeAt = \strpos($down, 'deleteAttribute'); + $this->assertNotFalse($deleteIndexAt); + $this->assertNotFalse($deleteAttributeAt); + $this->assertTrue($deleteIndexAt < $deleteAttributeAt); + } + + public function testFreshResetsTrackerBeforeMigrating(): void + { + $tracker = $this->createMock(Tracker::class); + $tracker->method('setup'); + $tracker->method('getAppliedVersions')->willReturn([]); + $tracker->method('getLastBatch')->willReturn(0); + $tracker->method('markApplied'); + $tracker->expects($this->once())->method('reset'); + + $this->db->method('listCollections')->willReturn([ + new Document(['$id' => 'users']), + ]); + $this->db->method('deleteCollection')->willReturn(true); + $this->db->method('withTransaction')->willReturnCallback(fn (callable $cb) => $cb()); + + $runner = new Runner($this->db, $tracker); + $runner->fresh([$this->createMigration('001')]); + } +} diff --git a/tests/unit/Schema/DiffTest.php b/tests/unit/Schema/DiffTest.php new file mode 100644 index 0000000000..1e0b56e626 --- /dev/null +++ b/tests/unit/Schema/DiffTest.php @@ -0,0 +1,247 @@ +differ = new Diff(); + } + + public function testNoChanges(): void + { + $collection = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + ], + ); + + $result = $this->differ->diff($collection, $collection); + + $this->assertFalse($result->hasChanges()); + $this->assertEmpty($result->changes); + } + + public function testDetectAddedAttribute(): void + { + $source = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + ], + ); + + $target = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'email'), + ], + ); + + $result = $this->differ->diff($source, $target); + + $this->assertTrue($result->hasChanges()); + $additions = $result->getAdditions(); + $this->assertCount(1, $additions); + $change = \array_values($additions)[0]; + $this->assertEquals(ChangeType::AddAttribute, $change->type); + $this->assertInstanceOf(Attribute::class, $change->attribute); + $this->assertEquals('email', $change->attribute->key); + } + + public function testDetectRemovedAttribute(): void + { + $source = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'email'), + ], + ); + + $target = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + ], + ); + + $result = $this->differ->diff($source, $target); + + $removals = $result->getRemovals(); + $this->assertCount(1, $removals); + $change = \array_values($removals)[0]; + $this->assertEquals(ChangeType::DropAttribute, $change->type); + $this->assertInstanceOf(Attribute::class, $change->attribute); + $this->assertEquals('email', $change->attribute->key); + } + + public function testDetectModifiedAttribute(): void + { + $source = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name', size: 100), + ], + ); + + $target = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + ], + ); + + $result = $this->differ->diff($source, $target); + + $modifications = $result->getModifications(); + $this->assertCount(1, $modifications); + $change = \array_values($modifications)[0]; + $this->assertEquals(ChangeType::ModifyAttribute, $change->type); + $this->assertInstanceOf(Attribute::class, $change->attribute); + $this->assertInstanceOf(Attribute::class, $change->previousAttribute); + $this->assertEquals(255, $change->attribute->size); + $this->assertEquals(100, $change->previousAttribute->size); + } + + public function testDetectAddedIndex(): void + { + $source = new Collection(id: 'test'); + $target = new Collection( + id: 'test', + indexes: [ + Index::index(key: 'idx_name', attributes: ['name']), + ], + ); + + $result = $this->differ->diff($source, $target); + + $additions = $result->getAdditions(); + $this->assertCount(1, $additions); + $change = \array_values($additions)[0]; + $this->assertEquals(ChangeType::AddIndex, $change->type); + $this->assertInstanceOf(Index::class, $change->index); + $this->assertEquals('idx_name', $change->index->key); + } + + public function testDetectModifiedIndexEmitsDropAndAdd(): void + { + $source = new Collection( + id: 'test', + indexes: [ + Index::index(key: 'idx_name', attributes: ['name']), + ], + ); + $target = new Collection( + id: 'test', + indexes: [ + Index::unique(key: 'idx_name', attributes: ['email']), + ], + ); + + $result = $this->differ->diff($source, $target); + + $this->assertTrue($result->hasChanges()); + $this->assertCount(1, $result->getRemovals()); + $this->assertCount(1, $result->getAdditions()); + $this->assertSame(ChangeType::DropIndex, \array_values($result->getRemovals())[0]->type); + $added = \array_values($result->getAdditions())[0]; + $this->assertSame(ChangeType::AddIndex, $added->type); + $this->assertNotNull($added->index); + $this->assertSame('email', $added->index->attributes[0]); + $this->assertSame('test', $added->collectionId); + } + + public function testDetectRemovedIndex(): void + { + $source = new Collection( + id: 'test', + indexes: [ + Index::index(key: 'idx_name', attributes: ['name']), + ], + ); + $target = new Collection(id: 'test'); + + $result = $this->differ->diff($source, $target); + + $removals = $result->getRemovals(); + $this->assertCount(1, $removals); + $change = \array_values($removals)[0]; + $this->assertEquals(ChangeType::DropIndex, $change->type); + } + + public function testComplexDiff(): void + { + $source = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name', size: 100), + Attribute::string(key: 'old_field', size: 50), + ], + indexes: [ + Index::index(key: 'idx_old', attributes: ['old_field']), + ], + ); + + $target = new Collection( + id: 'test', + attributes: [ + Attribute::string(key: 'name'), + Attribute::integer(key: 'new_field'), + ], + indexes: [ + Index::index(key: 'idx_new', attributes: ['new_field']), + ], + ); + + $result = $this->differ->diff($source, $target); + + $this->assertTrue($result->hasChanges()); + $this->assertNotEmpty($result->getAdditions()); + $this->assertNotEmpty($result->getRemovals()); + $this->assertNotEmpty($result->getModifications()); + } + + public function testApplyModifyAttributePassesNamedTypeNotAttribute(): void + { + $attribute = Attribute::string(key: 'name', size: 100, required: true); + $result = new DiffResult([ + new Change(ChangeType::ModifyAttribute, attribute: $attribute), + ]); + + $db = $this->createMock(Database::class); + $db->expects($this->once()) + ->method('updateAttribute') + ->with( + 'users', + 'name', + ColumnType::String, + 100, + true, + null, + true, + false, + null, + [], + [], + ); + + $result->apply($db, 'users'); + } +}