From c5605a5eb6c158ebf8259d66d01849c9f986c413 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 13:13:21 +1200 Subject: [PATCH 1/8] refactor: drop compound `databaseId:collectionId` resourceId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSV/JSON sources and destinations took a single composite "databaseId:tableId" string and split it on ':' internally. The Appwrite source did the same trick on `rootResourceId` to scope a transfer to a specific collection within a database. That mixed two identifiers in one slot, broke queryability for callers, and forced consumers to recompose the colon-form at the boundary. This change replaces the compound with explicit fields: - CSV/JSON Source/Destination constructors now take `string $databaseId, string $tableId` directly. No more `explode(':', $resourceId)`. - Target/Source/Destination/Transfer::run gain a new optional `$rootResourceChildId` parameter that, for database roots, scopes the transfer to a specific table/collection inside the root database. `$rootResourceId` keeps its existing semantics — it always matches `$rootResourceType` (a top-level resource). - Sources/Appwrite no longer inspects `rootResourceId` for ':' or splits it; it reads `$this->rootResourceChildId` when the caller wants per-collection filtering. Tests updated to call the new constructor signatures. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Migration/Destination.php | 6 ++++- src/Migration/Destinations/CSV.php | 13 ++++++---- src/Migration/Destinations/JSON.php | 13 ++++++---- src/Migration/Source.php | 7 +++-- src/Migration/Sources/Appwrite.php | 31 ++++++----------------- src/Migration/Sources/CSV.php | 23 ++++++++--------- src/Migration/Sources/JSON.php | 18 ++++++------- src/Migration/Target.php | 7 +++-- src/Migration/Transfer.php | 7 ++++- tests/Migration/Unit/General/CSVTest.php | 12 ++++----- tests/Migration/Unit/General/JSONTest.php | 6 +++-- 11 files changed, 75 insertions(+), 68 deletions(-) diff --git a/src/Migration/Destination.php b/src/Migration/Destination.php index f3206533..b3e42612 100644 --- a/src/Migration/Destination.php +++ b/src/Migration/Destination.php @@ -26,13 +26,16 @@ public function setSource(Source $source): self * * @param array $resources Resources to transfer * @param callable(array): void $callback to run after transfer - * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource + * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. + * @param string $rootResourceType Resource type for $rootResourceId. + * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. */ public function run( array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', + string $rootResourceChildId = '', ): void { $this->source->run( $resources, @@ -41,6 +44,7 @@ function (array $resources) use ($callback) { }, $rootResourceId, $rootResourceType, + $rootResourceChildId, ); } diff --git a/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php index 790e6a0a..d6d4b506 100644 --- a/src/Migration/Destinations/CSV.php +++ b/src/Migration/Destinations/CSV.php @@ -17,7 +17,8 @@ class CSV extends Destination { protected Device $deviceForFiles; - protected string $resourceId; + protected string $databaseId; + protected string $tableId; protected string $directory; protected string $outputFile; protected Local $local; @@ -32,7 +33,8 @@ class CSV extends Destination */ public function __construct( Device $deviceForFiles, - string $resourceId, + string $databaseId, + string $tableId, string $directory, string $filename, array $allowedColumns = [], @@ -42,7 +44,8 @@ public function __construct( private readonly bool $includeHeaders = true, ) { $this->deviceForFiles = $deviceForFiles; - $this->resourceId = $resourceId; + $this->databaseId = $databaseId; + $this->tableId = $tableId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); $this->local = new Local(\sys_get_temp_dir() . '/csv_export_' . uniqid()); @@ -168,7 +171,7 @@ public function shutdown(): void $destPath = $this->deviceForFiles->getPath($this->directory . '/' . $filename); if (!$this->local->exists($sourcePath)) { - throw new \Exception("No data to export for resource: $this->resourceId", MigrationException::CODE_NOT_FOUND); + throw new \Exception("No data to export for table {$this->tableId} in database {$this->databaseId}", MigrationException::CODE_NOT_FOUND); } try { @@ -193,7 +196,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->resourceId + $this->tableId )); } } diff --git a/src/Migration/Destinations/JSON.php b/src/Migration/Destinations/JSON.php index 6660ab5a..3cafc999 100644 --- a/src/Migration/Destinations/JSON.php +++ b/src/Migration/Destinations/JSON.php @@ -18,7 +18,8 @@ class JSON extends Destination { protected Device $deviceForFiles; - protected string $resourceId; + protected string $databaseId; + protected string $tableId; protected string $directory; protected string $outputFile; protected Local $local; @@ -36,13 +37,15 @@ class JSON extends Destination */ public function __construct( Device $deviceForFiles, - string $resourceId, + string $databaseId, + string $tableId, string $directory, string $filename, array $allowedColumns = [], ) { $this->deviceForFiles = $deviceForFiles; - $this->resourceId = $resourceId; + $this->databaseId = $databaseId; + $this->tableId = $tableId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); @@ -178,7 +181,7 @@ public function shutdown(): void $destPath = $this->deviceForFiles->getPath($this->directory . '/' . $filename); if (!$this->local->exists($sourcePath)) { - throw new Exception("No data to export for resource: $this->resourceId"); + throw new Exception("No data to export for table {$this->tableId} in database {$this->databaseId}"); } $handle = null; @@ -217,7 +220,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->resourceId + $this->tableId )); } } diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 9c126c02..c5c1aac9 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -85,12 +85,15 @@ public function callback(array $resources): void * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource + * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. + * @param string $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. + * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. */ - public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void + public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', string $rootResourceChildId = ''): void { $this->rootResourceId = $rootResourceId; $this->rootResourceType = $rootResourceType; + $this->rootResourceChildId = $rootResourceChildId; $this->transferCallback = function (array $returnedResources) use ($callback, $resources) { $prunedResources = []; diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 40ec3dee..5f5603de 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1074,17 +1074,7 @@ private function exportDatabases(int $batchSize, array $resources = []): void $queries = [$this->reader->queryLimit($batchSize)]; if ($this->rootResourceId !== '' && ($this->rootResourceType === Resource::TYPE_DATABASE || $this->rootResourceType === Resource::TYPE_DATABASE_DOCUMENTSDB)) { - $targetDatabaseId = $this->rootResourceId; - - // Handle database:collection format - extract database ID - if (\str_contains($this->rootResourceId, ':')) { - $parts = \explode(':', $this->rootResourceId, 2); - if (\count($parts) === 2) { - $targetDatabaseId = $parts[0]; - } - } - - $queries[] = $this->reader->queryEqual('$id', [$targetDatabaseId]); + $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceId]); $queries[] = $this->reader->queryLimit(1); } @@ -1148,24 +1138,19 @@ private function exportEntities(string $databaseName, int $batchSize): void $queries = [$this->reader->queryLimit($batchSize)]; $tables = []; - // Filter to specific table if rootResourceType is database with database:collection format + // Filter to a specific table when the root is a database with a child set, or + // when the root itself is a table. if ( - $this->rootResourceId !== '' && - $this->rootResourceType === Resource::TYPE_DATABASE && - \str_contains($this->rootResourceId, ':') + $this->rootResourceChildId !== '' && + $this->rootResourceType === Resource::TYPE_DATABASE ) { - $parts = \explode(':', $this->rootResourceId, 2); - if (\count($parts) === 2) { - $targetTableId = $parts[1]; // table ID - $queries[] = $this->reader->queryEqual('$id', [$targetTableId]); - $queries[] = $this->reader->queryLimit(1); - } + $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceChildId]); + $queries[] = $this->reader->queryLimit(1); } elseif ( $this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_TABLE ) { - $targetTableId = $this->rootResourceId; - $queries[] = $this->reader->queryEqual('$id', [$targetTableId]); + $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceId]); $queries[] = $this->reader->queryLimit(1); } elseif ($lastTable) { $queries[] = $this->reader->queryCursorAfter($lastTable); diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index 411a096d..ae064333 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -27,10 +27,9 @@ class CSV extends Source private string $filePath; - /** - * format: `{databaseId:tableId}` - */ - private string $resourceId; + private string $databaseId; + + private string $tableId; private Device $device; @@ -39,7 +38,8 @@ class CSV extends Source private bool $downloaded = false; public function __construct( - string $resourceId, + string $databaseId, + string $tableId, string $filePath, Device $device, ?UtopiaDatabase $dbForProject, @@ -47,7 +47,8 @@ public function __construct( ) { $this->device = $device; $this->filePath = $filePath; - $this->resourceId = $resourceId; + $this->databaseId = $databaseId; + $this->tableId = $tableId; $this->database = new DatabaseReader($dbForProject, $getDatabasesDB); } @@ -131,10 +132,8 @@ private function exportRows(int $batchSize): void $columns = []; $lastColumn = null; - [$databaseId, $tableId] = \explode(':', $this->resourceId); - $databases = $this->database->listDatabases([ - $this->database->queryEqual('$id', [$databaseId]), + $this->database->queryEqual('$id', [$this->databaseId]), $this->database->queryLimit(1), ]); @@ -157,8 +156,8 @@ private function exportRows(int $batchSize): void ]; $tablePayload = [ - 'id' => $tableId, - 'name' => $tableId, + 'id' => $this->tableId, + 'name' => $this->tableId, 'documentSecurity' => false, 'rowSecurity' => false, 'permissions' => [], @@ -520,7 +519,7 @@ private function validateCSVHeaders(array $headers, array $columnTypes, array $r UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, \implode(', ', $messages), - $this->resourceId + $this->tableId )); } } diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index 46124a09..4bb35e4c 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -22,10 +22,9 @@ class JSON extends Source { private string $filePath; - /** - * format: `{databaseId:tableId}` - */ - private string $resourceId; + private string $databaseId; + + private string $tableId; private Device $device; @@ -35,14 +34,16 @@ class JSON extends Source private bool $downloaded = false; public function __construct( - string $resourceId, + string $databaseId, + string $tableId, string $filePath, Device $device, ?UtopiaDatabase $dbForProject ) { $this->device = $device; $this->filePath = $filePath; - $this->resourceId = $resourceId; + $this->databaseId = $databaseId; + $this->tableId = $tableId; /* kept for composer check */ $this->dbForProject = $dbForProject; @@ -120,9 +121,8 @@ protected function exportGroupDatabases(int $batchSize, array $resources): void */ private function exportRows(int $batchSize): void { - [$databaseId, $tableId] = \explode(':', $this->resourceId); - $database = new Database($databaseId, ''); - $table = new Table($database, '', $tableId); + $database = new Database($this->databaseId, ''); + $table = new Table($database, '', $this->tableId); $this->withJsonItems(function ($items) use ($table, $batchSize) { $buffer = []; diff --git a/src/Migration/Target.php b/src/Migration/Target.php index 777609f2..8f76e24e 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -33,6 +33,8 @@ abstract class Target protected string $rootResourceId = ''; + protected string $rootResourceChildId = ''; + protected string $rootResourceType = ''; abstract public static function getName(): string; @@ -49,9 +51,10 @@ public function registerCache(Cache &$cache): void * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource + * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. + * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. */ - abstract public function run(array $resources, callable $callback, string $rootResourceId = ''): void; + abstract public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceChildId = ''): void; /** * Report Resources diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 267e4289..7801d284 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -320,7 +320,9 @@ public function getStatusCounters(): array * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string|null $rootResourceId Root resource ID, If enabled you can only transfer a single root resource + * @param string|null $rootResourceId Root resource ID. If set, only this root resource is transferred. + * @param string|null $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. + * @param string|null $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. * @throws \Exception */ public function run( @@ -328,11 +330,13 @@ public function run( callable $callback, ?string $rootResourceId = null, ?string $rootResourceType = null, + ?string $rootResourceChildId = null, ): void { // Allows you to push entire groups if you want. $computedResources = []; $rootResourceId = $rootResourceId ?? ''; $rootResourceType = $rootResourceType ?? ''; + $rootResourceChildId = $rootResourceChildId ?? ''; foreach ($resources as $resource) { if (is_array($resource)) { @@ -373,6 +377,7 @@ public function run( $callback, $rootResourceId, $rootResourceType, + $rootResourceChildId, ); } diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index e4fd3ec1..e7ef661d 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -81,7 +81,7 @@ public function testCSVExportBasic() $exportDevice = new Local($tempDir); // Create CSV destination - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); // Create test data $database = new Database('test_db'); @@ -156,7 +156,7 @@ public function testCSVExportWithSpecialCharacters() $tempDir = sys_get_temp_dir() . '/csv_test_special_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -204,7 +204,7 @@ public function testCSVExportWithArrays() $tempDir = sys_get_temp_dir() . '/csv_test_arrays_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -251,7 +251,7 @@ public function testCSVExportWithNullValues() $tempDir = sys_get_temp_dir() . '/csv_test_nulls_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -300,7 +300,7 @@ public function testCSVExportWithAllowedAttributes() $exportDevice = new Local($tempDir); // Only allow specific attributes - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id', ['name', 'email']); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id', ['name', 'email']); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -351,7 +351,7 @@ public function testCSVExportImportCompatibility() $exportDevice = new Local($tempDir); // Export data - $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); diff --git a/tests/Migration/Unit/General/JSONTest.php b/tests/Migration/Unit/General/JSONTest.php index 1a880ec7..4b425df4 100644 --- a/tests/Migration/Unit/General/JSONTest.php +++ b/tests/Migration/Unit/General/JSONTest.php @@ -198,7 +198,8 @@ public function testJSONExportWithAllowedAttributes() $jsonDestination = new DestinationJSON( new Local($tempDir), - 'test_db:test_table_id', + 'test_db', + 'test_table_id', '', 'test_db_test_table_id', ['name', 'email'] @@ -236,7 +237,8 @@ private function createDestination(string $tempDir): DestinationJSON { return new DestinationJSON( new Local($tempDir), - 'test_db:test_table_id', + 'test_db', + 'test_table_id', '', 'test_db_test_table_id' ); From 6e99bb8e153ad72af46ac391b78f1388c95fa5b9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 10 Jun 2026 17:12:50 +1200 Subject: [PATCH 2/8] fix: align Target::run signature with concrete overrides The abstract Target::run() omitted $rootResourceType, so its 4th positional parameter was $rootResourceChildId while both concrete overrides (Source::run, Destination::run) take $rootResourceType there. Callers typed against Target passing four positional args would silently route the child ID into the resource type. All three signatures now match exactly. Co-Authored-By: Claude Fable 5 --- src/Migration/Target.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Migration/Target.php b/src/Migration/Target.php index 8f76e24e..bcbe5953 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -52,9 +52,10 @@ public function registerCache(Cache &$cache): void * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. + * @param string $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. */ - abstract public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceChildId = ''): void; + abstract public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', string $rootResourceChildId = ''): void; /** * Report Resources From 4abc29b91ac3b6cd12491e04a1a23875fc3d48d0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 17 Jul 2026 22:18:23 +1200 Subject: [PATCH 3/8] fix: propagate child resource scope across adapters --- src/Migration/Destinations/Appwrite.php | 3 ++- src/Migration/Sources/Appwrite.php | 9 ++++--- src/Migration/Target.php | 4 +-- tests/Migration/Unit/Adapters/MockSource.php | 6 +++++ tests/Migration/Unit/General/CSVTest.php | 3 --- tests/Migration/Unit/General/TransferTest.php | 26 +++++++++++++++++++ 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index b45f3077..6468b746 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -255,9 +255,10 @@ public function run( callable $callback, string $rootResourceId = '', string $rootResourceType = '', + string $rootResourceChildId = '', ): void { $this->resetRunState(); - parent::run($resources, $callback, $rootResourceId, $rootResourceType); + parent::run($resources, $callback, $rootResourceId, $rootResourceType, $rootResourceChildId); // parent::run() returning means every resource transferred, so the databases are usable. // Flip status before the orphan sweep so a cleanup failure can't strand them in `provisioning`. $this->markProvisionedDatabasesReady(); diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 5f5603de..74877179 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1073,7 +1073,7 @@ private function exportDatabases(int $batchSize, array $resources = []): void while (true) { $queries = [$this->reader->queryLimit($batchSize)]; - if ($this->rootResourceId !== '' && ($this->rootResourceType === Resource::TYPE_DATABASE || $this->rootResourceType === Resource::TYPE_DATABASE_DOCUMENTSDB)) { + if ($this->rootResourceId !== '' && \array_key_exists($this->rootResourceType, Resource::DATABASE_TYPE_RESOURCE_MAP)) { $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceId]); $queries[] = $this->reader->queryLimit(1); } @@ -1138,11 +1138,12 @@ private function exportEntities(string $databaseName, int $batchSize): void $queries = [$this->reader->queryLimit($batchSize)]; $tables = []; - // Filter to a specific table when the root is a database with a child set, or - // when the root itself is a table. + // Filter to a specific table or collection when its database root has a child set, + // or when the root itself is a table. if ( + $this->rootResourceId !== '' && $this->rootResourceChildId !== '' && - $this->rootResourceType === Resource::TYPE_DATABASE + $this->rootResourceType === $databaseName ) { $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceChildId]); $queries[] = $this->reader->queryLimit(1); diff --git a/src/Migration/Target.php b/src/Migration/Target.php index bcbe5953..b16f3680 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -33,10 +33,10 @@ abstract class Target protected string $rootResourceId = ''; - protected string $rootResourceChildId = ''; - protected string $rootResourceType = ''; + protected string $rootResourceChildId = ''; + abstract public static function getName(): string; abstract public static function getSupportedResources(): array; diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index cbdf9602..835203d9 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -50,6 +50,12 @@ private function handleResourceTransfer(string $group, string $type): void return; } + $entityType = Resource::DATABASE_TYPE_RESOURCE_MAP[$this->rootResourceType]['entity'] ?? null; + if ($type === $entityType && $this->rootResourceId !== '' && $this->rootResourceChildId !== '') { + $this->callback([$this->getMockResourceById($group, $type, $this->rootResourceChildId)]); + return; + } + $resources = $this->getMockResourcesByType($group, $type) ?? []; $this->callback($resources); return; diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index e7ef661d..623de339 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -46,9 +46,6 @@ private function detectDelimiter($stream): string $refMethod = $reflection->getMethod('delimiter'); - /** @noinspection PhpExpressionResultUnusedInspection */ - $refMethod->setAccessible(true); - return $refMethod->invoke($instance, $stream); } diff --git a/tests/Migration/Unit/General/TransferTest.php b/tests/Migration/Unit/General/TransferTest.php index e4d95c1a..d5b1937e 100644 --- a/tests/Migration/Unit/General/TransferTest.php +++ b/tests/Migration/Unit/General/TransferTest.php @@ -67,6 +67,32 @@ function () { $this->assertSame('test', $database->getId()); } + public function testRootResourceChildIdScopesDatabaseEntity(): void + { + $database = new Database('database', 'Database'); + $first = new Table($database, 'First table', 'first'); + $second = new Table($database, 'Second table', 'second'); + + $this->source->pushMockResource($database); + $this->source->pushMockResource($first); + $this->source->pushMockResource($second); + + $this->transfer->run( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + $database->getId(), + Resource::TYPE_DATABASE, + $second->getId(), + ); + + $tables = $this->destination->getResourceTypeData(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE); + + $this->assertSame(['second'], $tables); + $this->assertNull($this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'first')); + $this->assertSame($second, $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'second')); + } + /** * Row and document counts are aggregated into the cache by status. When such * a count exists for a resource type that was not part of the migration From a7e94c86a96fc7cdfff3eae9845270d91f83e7f8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 18 Jul 2026 00:11:07 +1200 Subject: [PATCH 4/8] fix: guard database status by destination schema --- src/Migration/Destinations/Appwrite.php | 18 ++- .../AppwriteDatabaseStatusTest.php | 151 ++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 6468b746..daca793d 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -177,6 +177,9 @@ class Appwrite extends Destination */ private array $provisioningDatabases = []; + /** Whether the destination project's database metadata supports lifecycle status. */ + private ?bool $databaseStatusSupported = null; + /** * @param string $project * @param string $endpoint @@ -241,11 +244,22 @@ private function getSupportForDatabaseStatus(): bool // $source is a non-nullable typed property with no default; it is only set when the // destination runs through Transfer. Guard so direct createDatabase() calls (e.g. tests) // don't hit "must not be accessed before initialization". - if (! isset($this->source)) { + if (! isset($this->source) || ! $this->getSource()->supportsDatabaseStatus()) { return false; } - return $this->getSource()->supportsDatabaseStatus(); + if ($this->databaseStatusSupported !== null) { + return $this->databaseStatusSupported; + } + + $collection = $this->dbForProject->getCollection(self::META_DATABASES); + foreach ($collection->getAttribute('attributes', []) as $attribute) { + if ($attribute->getId() === 'status') { + return $this->databaseStatusSupported = true; + } + } + + return $this->databaseStatusSupported = false; } /** Orphan cleanup runs only after a successful migration — a mid-run throw preserves the destination as-is. */ diff --git a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php new file mode 100644 index 00000000..c661f6d8 --- /dev/null +++ b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php @@ -0,0 +1,151 @@ +createProjectDatabase(withStatus: false); + + $destination = $this->runDatabaseTransfer($database); + + $created = $this->getDatabaseDocument($database); + $this->assertSame([], $this->errorMessages($destination)); + $this->assertFalse($created->isEmpty()); + $this->assertArrayNotHasKey('status', $created->getArrayCopy()); + } + + public function testDatabaseCreationPreservesLifecycleWhenDestinationSchemaSupportsStatus(): void + { + $database = $this->createProjectDatabase(withStatus: true); + + $destination = $this->runDatabaseTransfer($database); + + $created = $this->getDatabaseDocument($database); + $this->assertSame([], $this->errorMessages($destination)); + $this->assertFalse($created->isEmpty()); + $this->assertSame('ready', $created->getAttribute('status')); + } + + private function createProjectDatabase(bool $withStatus): UtopiaDatabase + { + $database = new UtopiaDatabase( + new MemoryAdapter(), + new Cache(new MemoryCache()), + ); + $database + ->setDatabase('appwrite') + ->setNamespace('_project'); + $database->create(); + + $attributes = [ + $this->attribute('name', UtopiaDatabase::VAR_STRING, required: true, size: 256), + $this->attribute('enabled', UtopiaDatabase::VAR_BOOLEAN, default: true), + $this->attribute('search', UtopiaDatabase::VAR_STRING, size: 16384), + $this->attribute('originalId', UtopiaDatabase::VAR_STRING, size: UtopiaDatabase::LENGTH_KEY), + $this->attribute('type', UtopiaDatabase::VAR_STRING, default: 'tablesdb', size: 128), + $this->attribute('database', UtopiaDatabase::VAR_STRING, size: 2000), + ]; + + if ($withStatus) { + $attributes[] = $this->attribute('status', UtopiaDatabase::VAR_STRING, size: 16); + } + + $database->createCollection('databases', $attributes); + + return $database; + } + + private function attribute( + string $id, + string $type, + bool $required = false, + mixed $default = null, + int $size = 0, + ): UtopiaDocument { + return new UtopiaDocument([ + '$id' => $id, + 'type' => $type, + 'size' => $size, + 'required' => $required, + 'default' => $default, + 'array' => false, + 'signed' => true, + 'filters' => [], + ]); + } + + private function runDatabaseTransfer(UtopiaDatabase $database): AppwriteDestination + { + $source = new class () extends MockSource { + #[Override] + public function supportsDatabaseStatus(): bool + { + return true; + } + }; + $source->pushMockResource(new DatabaseResource( + id: 'database', + name: 'Database', + type: 'tablesdb', + database: 'source-dsn', + databaseStatus: 'ready', + )); + + $destination = new AppwriteDestination( + project: 'destination-project', + endpoint: 'http://example.test/v1', + key: 'test-key', + dbForProject: $database, + getDatabasesDB: static fn (UtopiaDocument $document): UtopiaDatabase => $database, + collectionStructure: ['attributes' => [], 'indexes' => []], + dbForPlatform: $database, + projectInternalId: '1', + onDuplicate: OnDuplicate::Fail, + ); + + $transfer = new Transfer($source, $destination); + $database->getAuthorization()->skip( + static fn () => $transfer->run( + [Resource::TYPE_DATABASE], + static function (): void { + }, + ), + ); + + return $destination; + } + + private function getDatabaseDocument(UtopiaDatabase $database): UtopiaDocument + { + return $database->getAuthorization()->skip( + static fn (): UtopiaDocument => $database->getDocument('databases', 'database'), + ); + } + + /** + * @return list + */ + private function errorMessages(AppwriteDestination $destination): array + { + return \array_map( + static fn (\Throwable $error): string => $error->getMessage(), + $destination->getErrors(), + ); + } +} From 07706c925bc31409bf73027d061c6a809121d6b4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 18 Jul 2026 08:30:21 +1200 Subject: [PATCH 5/8] fix: preserve migration API compatibility --- src/Migration/Destination.php | 63 +++++-- src/Migration/Destinations/Appwrite.php | 3 +- src/Migration/Destinations/CSV.php | 42 ++++- src/Migration/Destinations/JSON.php | 34 +++- src/Migration/Source.php | 70 +++++++- src/Migration/Sources/CSV.php | 56 ++++-- src/Migration/Sources/JSON.php | 52 +++++- src/Migration/Target.php | 6 +- src/Migration/Transfer.php | 59 ++++-- .../AppwriteDatabaseStatusTest.php | 79 +++++--- tests/Migration/Unit/General/CSVTest.php | 86 ++++++++- tests/Migration/Unit/General/JSONTest.php | 101 ++++++++++- tests/Migration/Unit/General/TargetTest.php | 61 +++++++ tests/Migration/Unit/General/TransferTest.php | 168 +++++++++++++++++- 14 files changed, 772 insertions(+), 108 deletions(-) create mode 100644 tests/Migration/Unit/General/TargetTest.php diff --git a/src/Migration/Destination.php b/src/Migration/Destination.php index b3e42612..5465d17c 100644 --- a/src/Migration/Destination.php +++ b/src/Migration/Destination.php @@ -4,6 +4,11 @@ abstract class Destination extends Target { + /** + * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null + */ + private ?array $resourceSelector = null; + /** * Source */ @@ -26,26 +31,58 @@ public function setSource(Source $source): self * * @param array $resources Resources to transfer * @param callable(array): void $callback to run after transfer - * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. - * @param string $rootResourceType Resource type for $rootResourceId. - * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. + * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource */ public function run( array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', - string $rootResourceChildId = '', ): void { - $this->source->run( - $resources, - function (array $resources) use ($callback) { - $this->import($resources, $callback); - }, - $rootResourceId, - $rootResourceType, - $rootResourceChildId, - ); + $import = function (array $resources) use ($callback) { + $this->import($resources, $callback); + }; + + if ($this->resourceSelector !== null) { + $this->source->runWithResourceSelector( + $resources, + $import, + $this->resourceSelector['rootResourceId'], + $this->resourceSelector['rootResourceType'], + $this->resourceSelector['rootResourceChildId'], + ); + + return; + } + + $this->source->run($resources, $import, $rootResourceId, $rootResourceType); + } + + /** + * Transfer resources using separate, opaque root and child IDs. + * + * @param array $resources Resources to transfer + * @param callable(array): void $callback to run after transfer + */ + public function runWithResourceSelector( + array $resources, + callable $callback, + string $rootResourceId, + string $rootResourceType, + string $rootResourceChildId, + ): void { + $previousResourceSelector = $this->resourceSelector; + $this->resourceSelector = [ + 'rootResourceId' => $rootResourceId, + 'rootResourceType' => $rootResourceType, + 'rootResourceChildId' => $rootResourceChildId, + ]; + + try { + $this->run($resources, $callback, $rootResourceId, $rootResourceType); + } finally { + $this->resourceSelector = $previousResourceSelector; + } } /** diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index daca793d..e1b31eb3 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -269,10 +269,9 @@ public function run( callable $callback, string $rootResourceId = '', string $rootResourceType = '', - string $rootResourceChildId = '', ): void { $this->resetRunState(); - parent::run($resources, $callback, $rootResourceId, $rootResourceType, $rootResourceChildId); + parent::run($resources, $callback, $rootResourceId, $rootResourceType); // parent::run() returning means every resource transferred, so the databases are usable. // Flip status before the orphan sweep so a cleanup failure can't strand them in `provisioning`. $this->markProvisionedDatabasesReady(); diff --git a/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php index d6d4b506..f0a0548b 100644 --- a/src/Migration/Destinations/CSV.php +++ b/src/Migration/Destinations/CSV.php @@ -17,8 +17,8 @@ class CSV extends Destination { protected Device $deviceForFiles; - protected string $databaseId; - protected string $tableId; + protected string $resourceId; + protected ?string $resourceChildId; protected string $directory; protected string $outputFile; protected Local $local; @@ -33,8 +33,7 @@ class CSV extends Destination */ public function __construct( Device $deviceForFiles, - string $databaseId, - string $tableId, + string $resourceId, string $directory, string $filename, array $allowedColumns = [], @@ -42,10 +41,11 @@ public function __construct( private readonly string $enclosure = '"', private readonly string $escape = '"', private readonly bool $includeHeaders = true, + ?string $resourceChildId = null, ) { $this->deviceForFiles = $deviceForFiles; - $this->databaseId = $databaseId; - $this->tableId = $tableId; + $this->resourceId = $resourceId; + $this->resourceChildId = $resourceChildId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); $this->local = new Local(\sys_get_temp_dir() . '/csv_export_' . uniqid()); @@ -57,6 +57,32 @@ public function __construct( } } + public static function fromResourceIds( + Device $deviceForFiles, + string $databaseId, + string $tableId, + string $directory, + string $filename, + array $allowedColumns = [], + string $delimiter = ',', + string $enclosure = '"', + string $escape = '"', + bool $includeHeaders = true, + ): self { + return new self( + deviceForFiles: $deviceForFiles, + resourceId: $databaseId, + directory: $directory, + filename: $filename, + allowedColumns: $allowedColumns, + delimiter: $delimiter, + enclosure: $enclosure, + escape: $escape, + includeHeaders: $includeHeaders, + resourceChildId: $tableId, + ); + } + public static function getName(): string { return 'CSV'; @@ -171,7 +197,7 @@ public function shutdown(): void $destPath = $this->deviceForFiles->getPath($this->directory . '/' . $filename); if (!$this->local->exists($sourcePath)) { - throw new \Exception("No data to export for table {$this->tableId} in database {$this->databaseId}", MigrationException::CODE_NOT_FOUND); + throw new \Exception("No data to export for resource: $this->resourceId", MigrationException::CODE_NOT_FOUND); } try { @@ -196,7 +222,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->tableId + $this->resourceChildId ?? $this->resourceId )); } } diff --git a/src/Migration/Destinations/JSON.php b/src/Migration/Destinations/JSON.php index 3cafc999..c8d32de3 100644 --- a/src/Migration/Destinations/JSON.php +++ b/src/Migration/Destinations/JSON.php @@ -18,8 +18,8 @@ class JSON extends Destination { protected Device $deviceForFiles; - protected string $databaseId; - protected string $tableId; + protected string $resourceId; + protected ?string $resourceChildId; protected string $directory; protected string $outputFile; protected Local $local; @@ -37,15 +37,15 @@ class JSON extends Destination */ public function __construct( Device $deviceForFiles, - string $databaseId, - string $tableId, + string $resourceId, string $directory, string $filename, array $allowedColumns = [], + ?string $resourceChildId = null, ) { $this->deviceForFiles = $deviceForFiles; - $this->databaseId = $databaseId; - $this->tableId = $tableId; + $this->resourceId = $resourceId; + $this->resourceChildId = $resourceChildId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); @@ -59,6 +59,24 @@ public function __construct( } } + public static function fromResourceIds( + Device $deviceForFiles, + string $databaseId, + string $tableId, + string $directory, + string $filename, + array $allowedColumns = [], + ): self { + return new self( + deviceForFiles: $deviceForFiles, + resourceId: $databaseId, + directory: $directory, + filename: $filename, + allowedColumns: $allowedColumns, + resourceChildId: $tableId, + ); + } + public static function getName(): string { return 'JSON'; @@ -181,7 +199,7 @@ public function shutdown(): void $destPath = $this->deviceForFiles->getPath($this->directory . '/' . $filename); if (!$this->local->exists($sourcePath)) { - throw new Exception("No data to export for table {$this->tableId} in database {$this->databaseId}"); + throw new Exception("No data to export for resource: $this->resourceId"); } $handle = null; @@ -220,7 +238,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->tableId + $this->resourceChildId ?? $this->resourceId )); } } diff --git a/src/Migration/Source.php b/src/Migration/Source.php index c5c1aac9..42179bb2 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -6,6 +6,11 @@ abstract class Source extends Target { protected static int $defaultBatchSize = 100; + /** + * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null + */ + private ?array $resourceSelector = null; + /** * @var callable(array): void $transferCallback */ @@ -85,15 +90,31 @@ public function callback(array $resources): void * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. - * @param string $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. - * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. + * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource */ - public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', string $rootResourceChildId = ''): void + public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void { - $this->rootResourceId = $rootResourceId; - $this->rootResourceType = $rootResourceType; - $this->rootResourceChildId = $rootResourceChildId; + $previousRootResourceId = $this->rootResourceId; + $previousRootResourceType = $this->rootResourceType; + $previousRootResourceChildId = $this->rootResourceChildId; + + if ($this->resourceSelector !== null) { + $this->rootResourceId = $this->resourceSelector['rootResourceId']; + $this->rootResourceType = $this->resourceSelector['rootResourceType']; + $this->rootResourceChildId = $this->resourceSelector['rootResourceChildId']; + } else { + $this->rootResourceId = $rootResourceId; + $this->rootResourceType = $rootResourceType; + $this->rootResourceChildId = ''; + + if ( + $this->rootResourceId !== '' + && \array_key_exists($this->rootResourceType, Resource::DATABASE_TYPE_RESOURCE_MAP) + && \str_contains($this->rootResourceId, ':') + ) { + [$this->rootResourceId, $this->rootResourceChildId] = \explode(':', $this->rootResourceId, 2); + } + } $this->transferCallback = function (array $returnedResources) use ($callback, $resources) { $prunedResources = []; @@ -118,7 +139,40 @@ public function run(array $resources, callable $callback, string $rootResourceId $this->cache->addAll($prunedResources); }; - $this->exportResources($resources); + try { + $this->exportResources($resources); + } finally { + $this->rootResourceId = $previousRootResourceId; + $this->rootResourceType = $previousRootResourceType; + $this->rootResourceChildId = $previousRootResourceChildId; + } + } + + /** + * Transfer resources using separate, opaque root and child IDs. + * + * @param array $resources Resources to transfer + * @param callable $callback Callback to run after transfer + */ + public function runWithResourceSelector( + array $resources, + callable $callback, + string $rootResourceId, + string $rootResourceType, + string $rootResourceChildId, + ): void { + $previousResourceSelector = $this->resourceSelector; + $this->resourceSelector = [ + 'rootResourceId' => $rootResourceId, + 'rootResourceType' => $rootResourceType, + 'rootResourceChildId' => $rootResourceChildId, + ]; + + try { + $this->run($resources, $callback, $rootResourceId, $rootResourceType); + } finally { + $this->resourceSelector = $previousResourceSelector; + } } /** diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index ae064333..8399f1b7 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -27,9 +27,12 @@ class CSV extends Source private string $filePath; - private string $databaseId; + /** + * Legacy format: `{databaseId}:{tableId}`. + */ + private string $resourceId; - private string $tableId; + private ?string $resourceChildId; private Device $device; @@ -38,20 +41,38 @@ class CSV extends Source private bool $downloaded = false; public function __construct( - string $databaseId, - string $tableId, + string $resourceId, string $filePath, Device $device, ?UtopiaDatabase $dbForProject, ?callable $getDatabasesDB = null, + ?string $resourceChildId = null, ) { $this->device = $device; $this->filePath = $filePath; - $this->databaseId = $databaseId; - $this->tableId = $tableId; + $this->resourceId = $resourceId; + $this->resourceChildId = $resourceChildId; $this->database = new DatabaseReader($dbForProject, $getDatabasesDB); } + public static function fromResourceIds( + string $databaseId, + string $tableId, + string $filePath, + Device $device, + ?UtopiaDatabase $dbForProject, + ?callable $getDatabasesDB = null, + ): self { + return new self( + resourceId: $databaseId, + filePath: $filePath, + device: $device, + dbForProject: $dbForProject, + getDatabasesDB: $getDatabasesDB, + resourceChildId: $tableId, + ); + } + public static function getName(): string { return 'CSV'; @@ -131,9 +152,10 @@ private function exportRows(int $batchSize): void { $columns = []; $lastColumn = null; + [$databaseId, $tableId] = $this->getResourceIds(); $databases = $this->database->listDatabases([ - $this->database->queryEqual('$id', [$this->databaseId]), + $this->database->queryEqual('$id', [$databaseId]), $this->database->queryLimit(1), ]); @@ -156,8 +178,8 @@ private function exportRows(int $batchSize): void ]; $tablePayload = [ - 'id' => $this->tableId, - 'name' => $this->tableId, + 'id' => $tableId, + 'name' => $tableId, 'documentSecurity' => false, 'rowSecurity' => false, 'permissions' => [], @@ -519,11 +541,25 @@ private function validateCSVHeaders(array $headers, array $columnTypes, array $r UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, \implode(', ', $messages), - $this->tableId + $this->resourceChildId ?? $this->resourceId )); } } + /** + * @return array{string, string} + */ + private function getResourceIds(): array + { + if ($this->resourceChildId !== null) { + return [$this->resourceId, $this->resourceChildId]; + } + + $resourceIds = \explode(':', $this->resourceId, 2); + + return [$resourceIds[0], $resourceIds[1] ?? '']; + } + /** * @throws \Exception */ diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index 4bb35e4c..c797ad98 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -22,9 +22,12 @@ class JSON extends Source { private string $filePath; - private string $databaseId; + /** + * Legacy format: `{databaseId}:{tableId}`. + */ + private string $resourceId; - private string $tableId; + private ?string $resourceChildId; private Device $device; @@ -34,21 +37,37 @@ class JSON extends Source private bool $downloaded = false; public function __construct( - string $databaseId, - string $tableId, + string $resourceId, string $filePath, Device $device, - ?UtopiaDatabase $dbForProject + ?UtopiaDatabase $dbForProject, + ?string $resourceChildId = null, ) { $this->device = $device; $this->filePath = $filePath; - $this->databaseId = $databaseId; - $this->tableId = $tableId; + $this->resourceId = $resourceId; + $this->resourceChildId = $resourceChildId; /* kept for composer check */ $this->dbForProject = $dbForProject; } + public static function fromResourceIds( + string $databaseId, + string $tableId, + string $filePath, + Device $device, + ?UtopiaDatabase $dbForProject, + ): self { + return new self( + resourceId: $databaseId, + filePath: $filePath, + device: $device, + dbForProject: $dbForProject, + resourceChildId: $tableId, + ); + } + public static function getName(): string { return 'JSON'; @@ -121,8 +140,9 @@ protected function exportGroupDatabases(int $batchSize, array $resources): void */ private function exportRows(int $batchSize): void { - $database = new Database($this->databaseId, ''); - $table = new Table($database, '', $this->tableId); + [$databaseId, $tableId] = $this->getResourceIds(); + $database = new Database($databaseId, ''); + $table = new Table($database, '', $tableId); $this->withJsonItems(function ($items) use ($table, $batchSize) { $buffer = []; @@ -161,6 +181,20 @@ private function exportRows(int $batchSize): void }); } + /** + * @return array{string, string} + */ + private function getResourceIds(): array + { + if ($this->resourceChildId !== null) { + return [$this->resourceId, $this->resourceChildId]; + } + + $resourceIds = \explode(':', $this->resourceId, 2); + + return [$resourceIds[0], $resourceIds[1] ?? '']; + } + /** * @throws \Exception */ diff --git a/src/Migration/Target.php b/src/Migration/Target.php index b16f3680..59af41a8 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -51,11 +51,9 @@ public function registerCache(Cache &$cache): void * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string $rootResourceId Root resource ID. If set, only this root resource is transferred. - * @param string $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. - * @param string $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. + * @param string $rootResourceId Root resource ID, If enabled you can only transfer a single root resource */ - abstract public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = '', string $rootResourceChildId = ''): void; + abstract public function run(array $resources, callable $callback, string $rootResourceId = ''): void; /** * Report Resources diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 7801d284..178a5cbb 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -216,6 +216,11 @@ class Transfer */ protected array $resources = []; + /** + * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null + */ + private ?array $resourceSelector = null; + public function __construct(Source $source, Destination $destination) { $this->source = $source; @@ -320,9 +325,7 @@ public function getStatusCounters(): array * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer - * @param string|null $rootResourceId Root resource ID. If set, only this root resource is transferred. - * @param string|null $rootResourceType Resource type for $rootResourceId. Required when $rootResourceId is set. - * @param string|null $rootResourceChildId Optional child filter under the root resource. For database roots, this is the collection/table ID. + * @param string|null $rootResourceId Root resource ID, If enabled you can only transfer a single root resource * @throws \Exception */ public function run( @@ -330,13 +333,11 @@ public function run( callable $callback, ?string $rootResourceId = null, ?string $rootResourceType = null, - ?string $rootResourceChildId = null, ): void { // Allows you to push entire groups if you want. $computedResources = []; $rootResourceId = $rootResourceId ?? ''; $rootResourceType = $rootResourceType ?? ''; - $rootResourceChildId = $rootResourceChildId ?? ''; foreach ($resources as $resource) { if (is_array($resource)) { @@ -372,13 +373,47 @@ public function run( $this->resources = $computedResources; - $this->destination->run( - $computedResources, - $callback, - $rootResourceId, - $rootResourceType, - $rootResourceChildId, - ); + if ($this->resourceSelector !== null) { + $this->destination->runWithResourceSelector( + $computedResources, + $callback, + $this->resourceSelector['rootResourceId'], + $this->resourceSelector['rootResourceType'], + $this->resourceSelector['rootResourceChildId'], + ); + + return; + } + + $this->destination->run($computedResources, $callback, $rootResourceId, $rootResourceType); + } + + /** + * Transfer resources using separate, opaque root and child IDs. + * + * @param array> $resources Resources to transfer + * @param callable $callback Callback to run after transfer + * @throws \Exception + */ + public function runWithResourceSelector( + array $resources, + callable $callback, + string $rootResourceId, + string $rootResourceType, + string $rootResourceChildId, + ): void { + $previousResourceSelector = $this->resourceSelector; + $this->resourceSelector = [ + 'rootResourceId' => $rootResourceId, + 'rootResourceType' => $rootResourceType, + 'rootResourceChildId' => $rootResourceChildId, + ]; + + try { + $this->run($resources, $callback, $rootResourceId, $rootResourceType); + } finally { + $this->resourceSelector = $previousResourceSelector; + } } /** diff --git a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php index c661f6d8..3c8f6dbc 100644 --- a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php +++ b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php @@ -16,30 +16,52 @@ use Utopia\Migration\Transfer; use Utopia\Tests\Unit\Adapters\MockSource; +class CountingAppwriteDestination extends AppwriteDestination +{ + public int $runCount = 0; + + #[Override] + public function run( + array $resources, + callable $callback, + string $rootResourceId = '', + string $rootResourceType = '', + ): void { + $this->runCount++; + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } +} + final class AppwriteDatabaseStatusTest extends TestCase { - public function testDatabaseCreationOmitsStatusWhenDestinationSchemaDoesNotSupportIt(): void + public function testDatabaseCreationOmitsStatusThroughLegacyAndExplicitEntrypoints(): void { - $database = $this->createProjectDatabase(withStatus: false); + foreach ([false, true] as $explicit) { + $database = $this->createProjectDatabase(withStatus: false); - $destination = $this->runDatabaseTransfer($database); + $destination = $this->runDatabaseTransfer($database, $explicit); - $created = $this->getDatabaseDocument($database); - $this->assertSame([], $this->errorMessages($destination)); - $this->assertFalse($created->isEmpty()); - $this->assertArrayNotHasKey('status', $created->getArrayCopy()); + $created = $this->getDatabaseDocument($database); + $this->assertSame([], $this->errorMessages($destination)); + $this->assertSame(1, $destination->runCount); + $this->assertFalse($created->isEmpty()); + $this->assertArrayNotHasKey('status', $created->getArrayCopy()); + } } - public function testDatabaseCreationPreservesLifecycleWhenDestinationSchemaSupportsStatus(): void + public function testDatabaseCreationPreservesLifecycleThroughLegacyAndExplicitEntrypoints(): void { - $database = $this->createProjectDatabase(withStatus: true); + foreach ([false, true] as $explicit) { + $database = $this->createProjectDatabase(withStatus: true); - $destination = $this->runDatabaseTransfer($database); + $destination = $this->runDatabaseTransfer($database, $explicit); - $created = $this->getDatabaseDocument($database); - $this->assertSame([], $this->errorMessages($destination)); - $this->assertFalse($created->isEmpty()); - $this->assertSame('ready', $created->getAttribute('status')); + $created = $this->getDatabaseDocument($database); + $this->assertSame([], $this->errorMessages($destination)); + $this->assertSame(1, $destination->runCount); + $this->assertFalse($created->isEmpty()); + $this->assertSame('ready', $created->getAttribute('status')); + } } private function createProjectDatabase(bool $withStatus): UtopiaDatabase @@ -90,7 +112,7 @@ private function attribute( ]); } - private function runDatabaseTransfer(UtopiaDatabase $database): AppwriteDestination + private function runDatabaseTransfer(UtopiaDatabase $database, bool $explicit): CountingAppwriteDestination { $source = new class () extends MockSource { #[Override] @@ -107,7 +129,7 @@ public function supportsDatabaseStatus(): bool databaseStatus: 'ready', )); - $destination = new AppwriteDestination( + $destination = new CountingAppwriteDestination( project: 'destination-project', endpoint: 'http://example.test/v1', key: 'test-key', @@ -121,11 +143,26 @@ public function supportsDatabaseStatus(): bool $transfer = new Transfer($source, $destination); $database->getAuthorization()->skip( - static fn () => $transfer->run( - [Resource::TYPE_DATABASE], - static function (): void { - }, - ), + static function () use ($explicit, $transfer): void { + if ($explicit) { + $transfer->runWithResourceSelector( + [Resource::TYPE_DATABASE], + static function (): void { + }, + 'database', + Resource::TYPE_DATABASE, + 'table', + ); + + return; + } + + $transfer->run( + [Resource::TYPE_DATABASE], + static function (): void { + }, + ); + }, ); return $destination; diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index 623de339..ce91f9df 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -2,7 +2,12 @@ namespace Utopia\Tests\Unit\General; +use Override; use PHPUnit\Framework\TestCase; +use Utopia\Cache\Adapter\Memory as MemoryCache; +use Utopia\Cache\Cache; +use Utopia\Database\Adapter\Memory as MemoryAdapter; +use Utopia\Database\Database as UtopiaDatabase; use Utopia\Migration\Destinations\CSV as DestinationCSV; use Utopia\Migration\Resources\Database\Database; use Utopia\Migration\Resources\Database\Row; @@ -26,6 +31,7 @@ public function getLocalRoot(): string } // Override shutdown to avoid transfer for testing + #[Override] public function shutdown(): void { // Do nothing for testing - don't transfer files @@ -36,6 +42,59 @@ class CSVTest extends TestCase { private const RESOURCES_DIR = __DIR__ . '/../../resources/csv/'; + public function testLegacyConstructorsAcceptPositionalAndNamedArguments(): void + { + $tempDir = sys_get_temp_dir() . '/csv_constructor_' . uniqid(); + $device = new Local($tempDir); + $database = $this->createDatabase(); + + $positionalSource = new CSV('database:table', 'input.csv', $device, $database); + $namedSource = new CSV( + resourceId: 'database:table', + filePath: 'input.csv', + device: $device, + dbForProject: $database, + ); + $positionalDestination = new TestCSV($device, 'database:table', '', 'output'); + $namedDestination = new TestCSV( + deviceForFiles: $device, + resourceId: 'database:table', + directory: '', + filename: 'output', + ); + + $this->assertInstanceOf(CSV::class, $positionalSource); + $this->assertInstanceOf(CSV::class, $namedSource); + $this->assertInstanceOf(DestinationCSV::class, $positionalDestination); + $this->assertInstanceOf(DestinationCSV::class, $namedDestination); + + $this->recursiveDelete($positionalDestination->getLocalRoot()); + $this->recursiveDelete($namedDestination->getLocalRoot()); + $this->recursiveDelete($tempDir); + } + + public function testFactoriesKeepColonContainingResourceIdsSeparate(): void + { + $tempDir = sys_get_temp_dir() . '/csv_factory_' . uniqid(); + $device = new Local($tempDir); + $database = $this->createDatabase(); + $databaseId = 'database:with:colon'; + $tableId = 'table:with:colon'; + + $source = CSV::fromResourceIds($databaseId, $tableId, 'input.csv', $device, $database); + $destination = DestinationCSV::fromResourceIds($device, $databaseId, $tableId, '', 'output'); + + $this->assertSame($databaseId, $this->property($source, 'resourceId')); + $this->assertSame($tableId, $this->property($source, 'resourceChildId')); + $this->assertSame($databaseId, $this->property($destination, 'resourceId')); + $this->assertSame($tableId, $this->property($destination, 'resourceChildId')); + + $local = $this->property($destination, 'local'); + $this->assertInstanceOf(Local::class, $local); + $this->recursiveDelete($local->getRoot()); + $this->recursiveDelete($tempDir); + } + /** * @throws \ReflectionException */ @@ -78,7 +137,7 @@ public function testCSVExportBasic() $exportDevice = new Local($tempDir); // Create CSV destination - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); // Create test data $database = new Database('test_db'); @@ -153,7 +212,7 @@ public function testCSVExportWithSpecialCharacters() $tempDir = sys_get_temp_dir() . '/csv_test_special_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -201,7 +260,7 @@ public function testCSVExportWithArrays() $tempDir = sys_get_temp_dir() . '/csv_test_arrays_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -248,7 +307,7 @@ public function testCSVExportWithNullValues() $tempDir = sys_get_temp_dir() . '/csv_test_nulls_' . uniqid(); $exportDevice = new Local($tempDir); - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -297,7 +356,7 @@ public function testCSVExportWithAllowedAttributes() $exportDevice = new Local($tempDir); // Only allow specific attributes - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id', ['name', 'email']); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id', ['name', 'email']); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -348,7 +407,7 @@ public function testCSVExportImportCompatibility() $exportDevice = new Local($tempDir); // Export data - $csvDestination = new TestCSV($exportDevice, 'test_db', 'test_table_id', '', 'test_db_test_table_id'); + $csvDestination = new TestCSV($exportDevice, 'test_db:test_table_id', '', 'test_db_test_table_id'); $database = new Database('test_db'); $table = new Table($database, 'test_table', 'test_table_id'); @@ -431,4 +490,19 @@ private function recursiveDelete(string $dir): void rmdir($dir); } } + + private function property(object $object, string $name): mixed + { + $property = new \ReflectionProperty($object, $name); + + return $property->getValue($object); + } + + private function createDatabase(): UtopiaDatabase + { + return new UtopiaDatabase( + new MemoryAdapter(), + new Cache(new MemoryCache()), + ); + } } diff --git a/tests/Migration/Unit/General/JSONTest.php b/tests/Migration/Unit/General/JSONTest.php index 4b425df4..4aac26d9 100644 --- a/tests/Migration/Unit/General/JSONTest.php +++ b/tests/Migration/Unit/General/JSONTest.php @@ -8,12 +8,92 @@ use Utopia\Migration\Resources\Database\Database; use Utopia\Migration\Resources\Database\Row; use Utopia\Migration\Resources\Database\Table; +use Utopia\Migration\Sources\JSON as SourceJSON; use Utopia\Migration\Transfer; use Utopia\Storage\Device\Local; +use Utopia\Tests\Unit\Adapters\MockDestination; use Utopia\Tests\Unit\Adapters\MockSource; class JSONTest extends TestCase { + public function testLegacyConstructorsAcceptPositionalAndNamedArguments(): void + { + $tempDir = sys_get_temp_dir() . '/json_constructor_' . uniqid(); + $device = new Local($tempDir); + + $positionalSource = new SourceJSON('database:table', 'input.json', $device, null); + $namedSource = new SourceJSON( + resourceId: 'database:table', + filePath: 'input.json', + device: $device, + dbForProject: null, + ); + $positionalDestination = new DestinationJSON($device, 'database:table', '', 'output'); + $namedDestination = new DestinationJSON( + deviceForFiles: $device, + resourceId: 'database:table', + directory: '', + filename: 'output', + ); + + $this->assertInstanceOf(SourceJSON::class, $positionalSource); + $this->assertInstanceOf(SourceJSON::class, $namedSource); + $this->assertInstanceOf(DestinationJSON::class, $positionalDestination); + $this->assertInstanceOf(DestinationJSON::class, $namedDestination); + + $this->recursiveDelete($this->localRoot($positionalDestination)); + $this->recursiveDelete($this->localRoot($namedDestination)); + $this->recursiveDelete($tempDir); + } + + public function testFactoriesKeepColonContainingResourceIdsSeparate(): void + { + $tempDir = sys_get_temp_dir() . '/json_factory_' . uniqid(); + $device = new Local($tempDir); + $databaseId = 'database:with:colon'; + $tableId = 'table:with:colon'; + + $source = SourceJSON::fromResourceIds($databaseId, $tableId, 'input.json', $device, null); + $destination = DestinationJSON::fromResourceIds($device, $databaseId, $tableId, '', 'output'); + + $this->assertSame($databaseId, $this->property($source, 'resourceId')); + $this->assertSame($tableId, $this->property($source, 'resourceChildId')); + $this->assertSame($databaseId, $this->property($destination, 'resourceId')); + $this->assertSame($tableId, $this->property($destination, 'resourceChildId')); + + $this->recursiveDelete($this->localRoot($destination)); + $this->recursiveDelete($tempDir); + } + + public function testSourceFactoryEmitsRowsWithSeparateColonContainingDatabaseAndTableIds(): void + { + $tempDir = sys_get_temp_dir() . '/json_source_factory_' . uniqid(); + \mkdir($tempDir, 0755, true); + $device = new Local($tempDir); + $filePath = $device->getPath('input.json'); + $databaseId = 'database:with:colon'; + $tableId = 'table:with:colon'; + + \file_put_contents($filePath, \json_encode([['$id' => 'row']]) ?: '[]'); + + $source = SourceJSON::fromResourceIds($databaseId, $tableId, $filePath, $device, null); + $destination = new MockDestination(); + $transfer = new Transfer($source, $destination); + $transfer->run( + [UtopiaResource::TYPE_ROW], + static function (): void { + }, + ); + + $row = $destination->getResourceById(Transfer::GROUP_DATABASES, UtopiaResource::TYPE_ROW, 'row'); + $this->assertInstanceOf(Row::class, $row); + $this->assertSame($tableId, $row->getTable()->getId()); + $this->assertSame($databaseId, $row->getTable()->getDatabase()->getId()); + $this->assertSame([], $source->getErrors()); + + $this->recursiveDelete($tempDir); + } + public function testJSONExportBasic() { $tempDir = sys_get_temp_dir() . '/json_test_' . uniqid(); @@ -198,8 +278,7 @@ public function testJSONExportWithAllowedAttributes() $jsonDestination = new DestinationJSON( new Local($tempDir), - 'test_db', - 'test_table_id', + 'test_db:test_table_id', '', 'test_db_test_table_id', ['name', 'email'] @@ -237,8 +316,7 @@ private function createDestination(string $tempDir): DestinationJSON { return new DestinationJSON( new Local($tempDir), - 'test_db', - 'test_table_id', + 'test_db:test_table_id', '', 'test_db_test_table_id' ); @@ -290,4 +368,19 @@ private function recursiveDelete(string $dir): void rmdir($dir); } } + + private function localRoot(DestinationJSON $destination): string + { + $local = $this->property($destination, 'local'); + $this->assertInstanceOf(Local::class, $local); + + return $local->getRoot(); + } + + private function property(object $object, string $name): mixed + { + $property = new \ReflectionProperty($object, $name); + + return $property->getValue($object); + } } diff --git a/tests/Migration/Unit/General/TargetTest.php b/tests/Migration/Unit/General/TargetTest.php new file mode 100644 index 00000000..083bef0e --- /dev/null +++ b/tests/Migration/Unit/General/TargetTest.php @@ -0,0 +1,61 @@ +, rootResourceId: string}|null */ + public ?array $invocation = null; + + #[Override] + public static function getName(): string + { + return 'Legacy'; + } + + #[Override] + public static function getSupportedResources(): array + { + return []; + } + + #[Override] + public function run(array $resources, callable $callback, string $rootResourceId = ''): void + { + $this->invocation = [ + 'resources' => $resources, + 'rootResourceId' => $rootResourceId, + ]; + $callback([]); + } + + #[Override] + public function report(array $resources = [], array $resourceIds = []): array + { + return []; + } + }; + + $callbackCount = 0; + $target->run( + ['database'], + static function () use (&$callbackCount): void { + $callbackCount++; + }, + 'database', + ); + + $this->assertSame(1, $callbackCount); + $this->assertSame([ + 'resources' => ['database'], + 'rootResourceId' => 'database', + ], $target->invocation); + } +} diff --git a/tests/Migration/Unit/General/TransferTest.php b/tests/Migration/Unit/General/TransferTest.php index d5b1937e..935631fd 100644 --- a/tests/Migration/Unit/General/TransferTest.php +++ b/tests/Migration/Unit/General/TransferTest.php @@ -2,6 +2,7 @@ namespace Utopia\Tests\Unit\General; +use Override; use PHPUnit\Framework\TestCase; use Utopia\Migration\Resource; use Utopia\Migration\Resources\Database\Database; @@ -67,7 +68,7 @@ function () { $this->assertSame('test', $database->getId()); } - public function testRootResourceChildIdScopesDatabaseEntity(): void + public function testLegacyCompoundRootResourceIdScopesDatabaseEntity(): void { $database = new Database('database', 'Database'); $first = new Table($database, 'First table', 'first'); @@ -81,9 +82,8 @@ public function testRootResourceChildIdScopesDatabaseEntity(): void [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], static function (): void { }, - $database->getId(), + $database->getId() . ':' . $second->getId(), Resource::TYPE_DATABASE, - $second->getId(), ); $tables = $this->destination->getResourceTypeData(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE); @@ -93,6 +93,168 @@ static function (): void { $this->assertSame($second, $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'second')); } + public function testExplicitSelectorKeepsColonContainingIdsOpaque(): void + { + $database = new Database('database:with:colon', 'Database'); + $first = new Table($database, 'First table', 'first'); + $second = new Table($database, 'Second table', 'table:with:colon'); + + $this->source->pushMockResource($database); + $this->source->pushMockResource($first); + $this->source->pushMockResource($second); + + $this->transfer->runWithResourceSelector( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + $database->getId(), + Resource::TYPE_DATABASE, + $second->getId(), + ); + + $this->assertSame( + ['table:with:colon'], + $this->destination->getResourceTypeData(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE), + ); + $this->assertSame( + $database, + $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_DATABASE, 'database:with:colon'), + ); + $this->assertSame( + $second, + $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'table:with:colon'), + ); + } + + public function testExplicitSelectorDispatchesLegacyOverridesExactlyOnce(): void + { + $source = new class () extends MockSource { + public int $runCount = 0; + + #[Override] + public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void + { + $this->runCount++; + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } + }; + $destination = new class () extends MockDestination { + public int $runCount = 0; + + #[Override] + public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void + { + $this->runCount++; + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } + }; + $transfer = new class ($source, $destination) extends Transfer { + public int $runCount = 0; + + #[Override] + public function run( + array $resources, + callable $callback, + ?string $rootResourceId = null, + ?string $rootResourceType = null, + ): void { + $this->runCount++; + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } + }; + + $database = new Database('database', 'Database'); + $table = new Table($database, 'Table', 'table'); + $source->pushMockResource($database); + $source->pushMockResource($table); + + $transfer->runWithResourceSelector( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + $database->getId(), + Resource::TYPE_DATABASE, + $table->getId(), + ); + + $this->assertSame(1, $transfer->runCount); + $this->assertSame(1, $destination->runCount); + $this->assertSame(1, $source->runCount); + } + + public function testExplicitSelectorStateIsRestoredAfterSuccess(): void + { + $selectedDatabase = new Database('selected', 'Selected'); + $selectedTable = new Table($selectedDatabase, 'Selected table', 'selected-table'); + $legacyDatabase = new Database('legacy', 'Legacy'); + $legacyTable = new Table($legacyDatabase, 'Legacy table', 'legacy-table'); + + foreach ([$selectedDatabase, $selectedTable, $legacyDatabase, $legacyTable] as $resource) { + $this->source->pushMockResource($resource); + } + + $this->transfer->runWithResourceSelector( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + $selectedDatabase->getId(), + Resource::TYPE_DATABASE, + $selectedTable->getId(), + ); + $this->transfer->run( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + 'legacy:legacy-table', + Resource::TYPE_DATABASE, + ); + + $this->assertSame( + $legacyTable, + $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'legacy-table'), + ); + } + + public function testExplicitSelectorStateIsRestoredAfterFailure(): void + { + $selectedDatabase = new Database('selected', 'Selected'); + $selectedTable = new Table($selectedDatabase, 'Selected table', 'selected-table'); + $legacyDatabase = new Database('legacy', 'Legacy'); + $legacyTable = new Table($legacyDatabase, 'Legacy table', 'legacy-table'); + + foreach ([$selectedDatabase, $selectedTable, $legacyDatabase, $legacyTable] as $resource) { + $this->source->pushMockResource($resource); + } + + try { + $this->transfer->runWithResourceSelector( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + throw new \RuntimeException('stop'); + }, + $selectedDatabase->getId(), + Resource::TYPE_DATABASE, + $selectedTable->getId(), + ); + $this->fail('The callback exception should escape the transfer.'); + } catch (\RuntimeException $error) { + $this->assertSame('stop', $error->getMessage()); + } + + $this->transfer->run( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + 'legacy:legacy-table', + Resource::TYPE_DATABASE, + ); + + $this->assertSame( + $legacyTable, + $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'legacy-table'), + ); + } + /** * Row and document counts are aggregated into the cache by status. When such * a count exists for a resource type that was not part of the migration From 5439476d98b3baac744ae7a7a5e38ae91b629e20 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 18 Jul 2026 08:44:07 +1200 Subject: [PATCH 6/8] fix: preserve selector inheritance compatibility --- src/Migration/Destinations/CSV.php | 2 +- src/Migration/Destinations/JSON.php | 2 +- src/Migration/Source.php | 40 +------------ src/Migration/Sources/Appwrite.php | 59 ++++++++++++++++++- src/Migration/Target.php | 2 - tests/Migration/Unit/Adapters/MockSource.php | 59 ++++++++++++++++++- tests/Migration/Unit/General/CSVTest.php | 24 ++++++++ tests/Migration/Unit/General/JSONTest.php | 24 ++++++++ tests/Migration/Unit/General/TransferTest.php | 29 +++++++++ 9 files changed, 196 insertions(+), 45 deletions(-) diff --git a/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php index f0a0548b..7be341fc 100644 --- a/src/Migration/Destinations/CSV.php +++ b/src/Migration/Destinations/CSV.php @@ -18,7 +18,7 @@ class CSV extends Destination { protected Device $deviceForFiles; protected string $resourceId; - protected ?string $resourceChildId; + private ?string $resourceChildId; protected string $directory; protected string $outputFile; protected Local $local; diff --git a/src/Migration/Destinations/JSON.php b/src/Migration/Destinations/JSON.php index c8d32de3..d108253f 100644 --- a/src/Migration/Destinations/JSON.php +++ b/src/Migration/Destinations/JSON.php @@ -19,7 +19,7 @@ class JSON extends Destination { protected Device $deviceForFiles; protected string $resourceId; - protected ?string $resourceChildId; + private ?string $resourceChildId; protected string $directory; protected string $outputFile; protected Local $local; diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 42179bb2..0a2897c5 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -6,11 +6,6 @@ abstract class Source extends Target { protected static int $defaultBatchSize = 100; - /** - * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null - */ - private ?array $resourceSelector = null; - /** * @var callable(array): void $transferCallback */ @@ -96,25 +91,8 @@ public function run(array $resources, callable $callback, string $rootResourceId { $previousRootResourceId = $this->rootResourceId; $previousRootResourceType = $this->rootResourceType; - $previousRootResourceChildId = $this->rootResourceChildId; - - if ($this->resourceSelector !== null) { - $this->rootResourceId = $this->resourceSelector['rootResourceId']; - $this->rootResourceType = $this->resourceSelector['rootResourceType']; - $this->rootResourceChildId = $this->resourceSelector['rootResourceChildId']; - } else { - $this->rootResourceId = $rootResourceId; - $this->rootResourceType = $rootResourceType; - $this->rootResourceChildId = ''; - - if ( - $this->rootResourceId !== '' - && \array_key_exists($this->rootResourceType, Resource::DATABASE_TYPE_RESOURCE_MAP) - && \str_contains($this->rootResourceId, ':') - ) { - [$this->rootResourceId, $this->rootResourceChildId] = \explode(':', $this->rootResourceId, 2); - } - } + $this->rootResourceId = $rootResourceId; + $this->rootResourceType = $rootResourceType; $this->transferCallback = function (array $returnedResources) use ($callback, $resources) { $prunedResources = []; @@ -144,7 +122,6 @@ public function run(array $resources, callable $callback, string $rootResourceId } finally { $this->rootResourceId = $previousRootResourceId; $this->rootResourceType = $previousRootResourceType; - $this->rootResourceChildId = $previousRootResourceChildId; } } @@ -161,18 +138,7 @@ public function runWithResourceSelector( string $rootResourceType, string $rootResourceChildId, ): void { - $previousResourceSelector = $this->resourceSelector; - $this->resourceSelector = [ - 'rootResourceId' => $rootResourceId, - 'rootResourceType' => $rootResourceType, - 'rootResourceChildId' => $rootResourceChildId, - ]; - - try { - $this->run($resources, $callback, $rootResourceId, $rootResourceType); - } finally { - $this->resourceSelector = $previousResourceSelector; - } + $this->run($resources, $callback, $rootResourceId, $rootResourceType); } /** diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 74877179..f7e9d86f 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -18,6 +18,7 @@ use Appwrite\Services\Teams; use Appwrite\Services\Users; use Appwrite\Services\Webhooks; +use Override; use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\DateTime as UtopiaDateTime; use Utopia\Database\Document as UtopiaDocument; @@ -117,6 +118,8 @@ class Appwrite extends Source private Proxy $proxy; + private ?string $resourceChildId = null; + /** * @var callable(UtopiaDocument $database|null): UtopiaDatabase */ @@ -180,6 +183,58 @@ public static function getName(): string return 'Appwrite'; } + #[Override] + public function run( + array $resources, + callable $callback, + string $rootResourceId = '', + string $rootResourceType = '', + ): void { + $previousResourceChildId = $this->resourceChildId; + + if ($this->resourceChildId === null) { + $this->resourceChildId = ''; + + if ( + $rootResourceId !== '' + && \array_key_exists($rootResourceType, Resource::DATABASE_TYPE_RESOURCE_MAP) + && \str_contains($rootResourceId, ':') + ) { + [$rootResourceId, $this->resourceChildId] = \explode(':', $rootResourceId, 2); + } + } + + try { + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } finally { + $this->resourceChildId = $previousResourceChildId; + } + } + + #[Override] + public function runWithResourceSelector( + array $resources, + callable $callback, + string $rootResourceId, + string $rootResourceType, + string $rootResourceChildId, + ): void { + $previousResourceChildId = $this->resourceChildId; + $this->resourceChildId = $rootResourceChildId; + + try { + parent::runWithResourceSelector( + $resources, + $callback, + $rootResourceId, + $rootResourceType, + $rootResourceChildId, + ); + } finally { + $this->resourceChildId = $previousResourceChildId; + } + } + public function getSourceType(): string { return $this->source; @@ -1142,10 +1197,10 @@ private function exportEntities(string $databaseName, int $batchSize): void // or when the root itself is a table. if ( $this->rootResourceId !== '' && - $this->rootResourceChildId !== '' && + $this->resourceChildId !== '' && $this->rootResourceType === $databaseName ) { - $queries[] = $this->reader->queryEqual('$id', [$this->rootResourceChildId]); + $queries[] = $this->reader->queryEqual('$id', [$this->resourceChildId]); $queries[] = $this->reader->queryLimit(1); } elseif ( $this->rootResourceId !== '' && diff --git a/src/Migration/Target.php b/src/Migration/Target.php index 59af41a8..777609f2 100644 --- a/src/Migration/Target.php +++ b/src/Migration/Target.php @@ -35,8 +35,6 @@ abstract class Target protected string $rootResourceType = ''; - protected string $rootResourceChildId = ''; - abstract public static function getName(): string; abstract public static function getSupportedResources(): array; diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 835203d9..50822a74 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -2,6 +2,7 @@ namespace Utopia\Tests\Unit\Adapters; +use Override; use Utopia\Migration\Resource; use Utopia\Migration\Source; use Utopia\Migration\Transfer; @@ -10,6 +11,60 @@ class MockSource extends Source { private array $mockResources = []; + private ?string $resourceChildId = null; + + #[Override] + public function run( + array $resources, + callable $callback, + string $rootResourceId = '', + string $rootResourceType = '', + ): void { + $previousResourceChildId = $this->resourceChildId; + + if ($this->resourceChildId === null) { + $this->resourceChildId = ''; + + if ( + $rootResourceId !== '' + && \array_key_exists($rootResourceType, Resource::DATABASE_TYPE_RESOURCE_MAP) + && \str_contains($rootResourceId, ':') + ) { + [$rootResourceId, $this->resourceChildId] = \explode(':', $rootResourceId, 2); + } + } + + try { + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } finally { + $this->resourceChildId = $previousResourceChildId; + } + } + + #[Override] + public function runWithResourceSelector( + array $resources, + callable $callback, + string $rootResourceId, + string $rootResourceType, + string $rootResourceChildId, + ): void { + $previousResourceChildId = $this->resourceChildId; + $this->resourceChildId = $rootResourceChildId; + + try { + parent::runWithResourceSelector( + $resources, + $callback, + $rootResourceId, + $rootResourceType, + $rootResourceChildId, + ); + } finally { + $this->resourceChildId = $previousResourceChildId; + } + } + public function pushMockResource(Resource $resource): void { if (!array_key_exists($resource->getGroup(), $this->mockResources)) { @@ -51,8 +106,8 @@ private function handleResourceTransfer(string $group, string $type): void } $entityType = Resource::DATABASE_TYPE_RESOURCE_MAP[$this->rootResourceType]['entity'] ?? null; - if ($type === $entityType && $this->rootResourceId !== '' && $this->rootResourceChildId !== '') { - $this->callback([$this->getMockResourceById($group, $type, $this->rootResourceChildId)]); + if ($type === $entityType && $this->rootResourceId !== '' && $this->resourceChildId !== '') { + $this->callback([$this->getMockResourceById($group, $type, $this->resourceChildId)]); return; } diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index ce91f9df..501d5952 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -95,6 +95,30 @@ public function testFactoriesKeepColonContainingResourceIdsSeparate(): void $this->recursiveDelete($tempDir); } + public function testDestinationSubclassChildSelectorPropertyRemainsCompatible(): void + { + $tempDir = sys_get_temp_dir() . '/csv_subclass_' . uniqid(); + $device = new Local($tempDir); + $destination = new class ($device, 'database:table', '', 'output') extends DestinationCSV { + protected $resourceChildId = ['external']; + + public function getExternalResourceChildId(): mixed + { + return $this->resourceChildId; + } + + public function getLocalRoot(): string + { + return $this->local->getRoot(); + } + }; + + $this->assertSame(['external'], $destination->getExternalResourceChildId()); + + $this->recursiveDelete($destination->getLocalRoot()); + $this->recursiveDelete($tempDir); + } + /** * @throws \ReflectionException */ diff --git a/tests/Migration/Unit/General/JSONTest.php b/tests/Migration/Unit/General/JSONTest.php index 4aac26d9..2daca0ec 100644 --- a/tests/Migration/Unit/General/JSONTest.php +++ b/tests/Migration/Unit/General/JSONTest.php @@ -65,6 +65,30 @@ public function testFactoriesKeepColonContainingResourceIdsSeparate(): void $this->recursiveDelete($tempDir); } + public function testDestinationSubclassChildSelectorPropertyRemainsCompatible(): void + { + $tempDir = sys_get_temp_dir() . '/json_subclass_' . uniqid(); + $device = new Local($tempDir); + $destination = new class ($device, 'database:table', '', 'output') extends DestinationJSON { + protected $resourceChildId = ['external']; + + public function getExternalResourceChildId(): mixed + { + return $this->resourceChildId; + } + + public function getLocalRoot(): string + { + return $this->local->getRoot(); + } + }; + + $this->assertSame(['external'], $destination->getExternalResourceChildId()); + + $this->recursiveDelete($destination->getLocalRoot()); + $this->recursiveDelete($tempDir); + } + public function testSourceFactoryEmitsRowsWithSeparateColonContainingDatabaseAndTableIds(): void { $tempDir = sys_get_temp_dir() . '/json_source_factory_' . uniqid(); diff --git a/tests/Migration/Unit/General/TransferTest.php b/tests/Migration/Unit/General/TransferTest.php index 935631fd..c49c3edf 100644 --- a/tests/Migration/Unit/General/TransferTest.php +++ b/tests/Migration/Unit/General/TransferTest.php @@ -93,6 +93,35 @@ static function (): void { $this->assertSame($second, $this->destination->getResourceById(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE, 'second')); } + public function testLegacySourceSubclassChildSelectorPropertyRemainsCompatible(): void + { + $source = new class () extends MockSource { + protected $rootResourceChildId = ['external']; + }; + $destination = new MockDestination(); + $transfer = new Transfer($source, $destination); + $database = new Database('database', 'Database'); + $first = new Table($database, 'First table', 'first'); + $second = new Table($database, 'Second table', 'second'); + + $source->pushMockResource($database); + $source->pushMockResource($first); + $source->pushMockResource($second); + + $transfer->run( + [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + static function (): void { + }, + 'database:second', + Resource::TYPE_DATABASE, + ); + + $this->assertSame( + ['second'], + $destination->getResourceTypeData(Transfer::GROUP_DATABASES, Resource::TYPE_TABLE), + ); + } + public function testExplicitSelectorKeepsColonContainingIdsOpaque(): void { $database = new Database('database:with:colon', 'Database'); From 7ed62ca2e2279bf30fa2018655e045c59bb5bc3d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 18 Jul 2026 08:50:27 +1200 Subject: [PATCH 7/8] fix: preserve exact adapter constructor signatures --- src/Migration/Destinations/CSV.php | 10 +++--- src/Migration/Destinations/JSON.php | 10 +++--- src/Migration/Sources/CSV.php | 10 +++--- src/Migration/Sources/JSON.php | 10 +++--- tests/Migration/Unit/General/CSVTest.php | 43 +++++++++++++++++++++++ tests/Migration/Unit/General/JSONTest.php | 38 ++++++++++++++++++++ 6 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php index 7be341fc..f5b7c603 100644 --- a/src/Migration/Destinations/CSV.php +++ b/src/Migration/Destinations/CSV.php @@ -18,7 +18,7 @@ class CSV extends Destination { protected Device $deviceForFiles; protected string $resourceId; - private ?string $resourceChildId; + private ?string $resourceChildId = null; protected string $directory; protected string $outputFile; protected Local $local; @@ -41,11 +41,9 @@ public function __construct( private readonly string $enclosure = '"', private readonly string $escape = '"', private readonly bool $includeHeaders = true, - ?string $resourceChildId = null, ) { $this->deviceForFiles = $deviceForFiles; $this->resourceId = $resourceId; - $this->resourceChildId = $resourceChildId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); $this->local = new Local(\sys_get_temp_dir() . '/csv_export_' . uniqid()); @@ -69,7 +67,7 @@ public static function fromResourceIds( string $escape = '"', bool $includeHeaders = true, ): self { - return new self( + $destination = new self( deviceForFiles: $deviceForFiles, resourceId: $databaseId, directory: $directory, @@ -79,8 +77,10 @@ public static function fromResourceIds( enclosure: $enclosure, escape: $escape, includeHeaders: $includeHeaders, - resourceChildId: $tableId, ); + $destination->resourceChildId = $tableId; + + return $destination; } public static function getName(): string diff --git a/src/Migration/Destinations/JSON.php b/src/Migration/Destinations/JSON.php index d108253f..12425742 100644 --- a/src/Migration/Destinations/JSON.php +++ b/src/Migration/Destinations/JSON.php @@ -19,7 +19,7 @@ class JSON extends Destination { protected Device $deviceForFiles; protected string $resourceId; - private ?string $resourceChildId; + private ?string $resourceChildId = null; protected string $directory; protected string $outputFile; protected Local $local; @@ -41,11 +41,9 @@ public function __construct( string $directory, string $filename, array $allowedColumns = [], - ?string $resourceChildId = null, ) { $this->deviceForFiles = $deviceForFiles; $this->resourceId = $resourceId; - $this->resourceChildId = $resourceChildId; $this->directory = $directory; $this->outputFile = $this->sanitizeFilename($filename); @@ -67,14 +65,16 @@ public static function fromResourceIds( string $filename, array $allowedColumns = [], ): self { - return new self( + $destination = new self( deviceForFiles: $deviceForFiles, resourceId: $databaseId, directory: $directory, filename: $filename, allowedColumns: $allowedColumns, - resourceChildId: $tableId, ); + $destination->resourceChildId = $tableId; + + return $destination; } public static function getName(): string diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index 8399f1b7..38424d4c 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -32,7 +32,7 @@ class CSV extends Source */ private string $resourceId; - private ?string $resourceChildId; + private ?string $resourceChildId = null; private Device $device; @@ -46,12 +46,10 @@ public function __construct( Device $device, ?UtopiaDatabase $dbForProject, ?callable $getDatabasesDB = null, - ?string $resourceChildId = null, ) { $this->device = $device; $this->filePath = $filePath; $this->resourceId = $resourceId; - $this->resourceChildId = $resourceChildId; $this->database = new DatabaseReader($dbForProject, $getDatabasesDB); } @@ -63,14 +61,16 @@ public static function fromResourceIds( ?UtopiaDatabase $dbForProject, ?callable $getDatabasesDB = null, ): self { - return new self( + $source = new self( resourceId: $databaseId, filePath: $filePath, device: $device, dbForProject: $dbForProject, getDatabasesDB: $getDatabasesDB, - resourceChildId: $tableId, ); + $source->resourceChildId = $tableId; + + return $source; } public static function getName(): string diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index c797ad98..e4613d66 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -27,7 +27,7 @@ class JSON extends Source */ private string $resourceId; - private ?string $resourceChildId; + private ?string $resourceChildId = null; private Device $device; @@ -41,12 +41,10 @@ public function __construct( string $filePath, Device $device, ?UtopiaDatabase $dbForProject, - ?string $resourceChildId = null, ) { $this->device = $device; $this->filePath = $filePath; $this->resourceId = $resourceId; - $this->resourceChildId = $resourceChildId; /* kept for composer check */ $this->dbForProject = $dbForProject; @@ -59,13 +57,15 @@ public static function fromResourceIds( Device $device, ?UtopiaDatabase $dbForProject, ): self { - return new self( + $source = new self( resourceId: $databaseId, filePath: $filePath, device: $device, dbForProject: $dbForProject, - resourceChildId: $tableId, ); + $source->resourceChildId = $tableId; + + return $source; } public static function getName(): string diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index 501d5952..837216ce 100644 --- a/tests/Migration/Unit/General/CSVTest.php +++ b/tests/Migration/Unit/General/CSVTest.php @@ -73,6 +73,28 @@ public function testLegacyConstructorsAcceptPositionalAndNamedArguments(): void $this->recursiveDelete($tempDir); } + public function testConstructorsMatchOriginMainSignatures(): void + { + $this->assertSame([ + ['resourceId', 'string', false, null], + ['filePath', 'string', false, null], + ['device', 'Utopia\\Storage\\Device', false, null], + ['dbForProject', '?Utopia\\Database\\Database', false, null], + ['getDatabasesDB', '?callable', true, null], + ], $this->constructorSignature(CSV::class)); + $this->assertSame([ + ['deviceForFiles', 'Utopia\\Storage\\Device', false, null], + ['resourceId', 'string', false, null], + ['directory', 'string', false, null], + ['filename', 'string', false, null], + ['allowedColumns', 'array', true, []], + ['delimiter', 'string', true, ','], + ['enclosure', 'string', true, '"'], + ['escape', 'string', true, '"'], + ['includeHeaders', 'bool', true, true], + ], $this->constructorSignature(DestinationCSV::class)); + } + public function testFactoriesKeepColonContainingResourceIdsSeparate(): void { $tempDir = sys_get_temp_dir() . '/csv_factory_' . uniqid(); @@ -522,6 +544,27 @@ private function property(object $object, string $name): mixed return $property->getValue($object); } + /** + * @param class-string $class + * @return list + */ + private function constructorSignature(string $class): array + { + $constructor = (new \ReflectionClass($class))->getConstructor(); + $this->assertNotNull($constructor); + + return \array_map(static function (\ReflectionParameter $parameter): array { + $hasDefault = $parameter->isDefaultValueAvailable(); + + return [ + $parameter->getName(), + (string) $parameter->getType(), + $hasDefault, + $hasDefault ? $parameter->getDefaultValue() : null, + ]; + }, $constructor->getParameters()); + } + private function createDatabase(): UtopiaDatabase { return new UtopiaDatabase( diff --git a/tests/Migration/Unit/General/JSONTest.php b/tests/Migration/Unit/General/JSONTest.php index 2daca0ec..ca32ccdb 100644 --- a/tests/Migration/Unit/General/JSONTest.php +++ b/tests/Migration/Unit/General/JSONTest.php @@ -46,6 +46,23 @@ public function testLegacyConstructorsAcceptPositionalAndNamedArguments(): void $this->recursiveDelete($tempDir); } + public function testConstructorsMatchOriginMainSignatures(): void + { + $this->assertSame([ + ['resourceId', 'string', false, null], + ['filePath', 'string', false, null], + ['device', 'Utopia\\Storage\\Device', false, null], + ['dbForProject', '?Utopia\\Database\\Database', false, null], + ], $this->constructorSignature(SourceJSON::class)); + $this->assertSame([ + ['deviceForFiles', 'Utopia\\Storage\\Device', false, null], + ['resourceId', 'string', false, null], + ['directory', 'string', false, null], + ['filename', 'string', false, null], + ['allowedColumns', 'array', true, []], + ], $this->constructorSignature(DestinationJSON::class)); + } + public function testFactoriesKeepColonContainingResourceIdsSeparate(): void { $tempDir = sys_get_temp_dir() . '/json_factory_' . uniqid(); @@ -407,4 +424,25 @@ private function property(object $object, string $name): mixed return $property->getValue($object); } + + /** + * @param class-string $class + * @return list + */ + private function constructorSignature(string $class): array + { + $constructor = (new \ReflectionClass($class))->getConstructor(); + $this->assertNotNull($constructor); + + return \array_map(static function (\ReflectionParameter $parameter): array { + $hasDefault = $parameter->isDefaultValueAvailable(); + + return [ + $parameter->getName(), + (string) $parameter->getType(), + $hasDefault, + $hasDefault ? $parameter->getDefaultValue() : null, + ]; + }, $constructor->getParameters()); + } } From 3a93efd027ebfa9adf9bbeb728073097a19c9cec Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 20 Jul 2026 21:46:54 +1200 Subject: [PATCH 8/8] (refactor): align migration resource selectors --- src/Migration/Destination.php | 45 +++++++---- src/Migration/ResourceSelector.php | 27 +++++++ src/Migration/Source.php | 22 ++++-- src/Migration/Sources/Appwrite.php | 20 +++-- src/Migration/Transfer.php | 45 +++++++---- tests/Migration/Unit/Adapters/MockSource.php | 20 +++-- .../AppwriteDatabaseStatusTest.php | 9 ++- tests/Migration/Unit/General/TransferTest.php | 78 ++++++++++++++++--- 8 files changed, 198 insertions(+), 68 deletions(-) create mode 100644 src/Migration/ResourceSelector.php diff --git a/src/Migration/Destination.php b/src/Migration/Destination.php index 5465d17c..14776727 100644 --- a/src/Migration/Destination.php +++ b/src/Migration/Destination.php @@ -4,10 +4,7 @@ abstract class Destination extends Target { - /** - * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null - */ - private ?array $resourceSelector = null; + private ?ResourceSelector $resourceSelector = null; /** * Source @@ -47,9 +44,12 @@ public function run( $this->source->runWithResourceSelector( $resources, $import, - $this->resourceSelector['rootResourceId'], - $this->resourceSelector['rootResourceType'], - $this->resourceSelector['rootResourceChildId'], + $this->resourceSelector->resourceId, + $this->resourceSelector->resourceInternalId, + $this->resourceSelector->resourceType, + $this->resourceSelector->parentResourceId, + $this->resourceSelector->parentResourceInternalId, + $this->resourceSelector->parentResourceType, ); return; @@ -59,7 +59,7 @@ public function run( } /** - * Transfer resources using separate, opaque root and child IDs. + * Transfer resources using Appwrite's canonical resource relation fields. * * @param array $resources Resources to transfer * @param callable(array): void $callback to run after transfer @@ -67,19 +67,30 @@ public function run( public function runWithResourceSelector( array $resources, callable $callback, - string $rootResourceId, - string $rootResourceType, - string $rootResourceChildId, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, ): void { $previousResourceSelector = $this->resourceSelector; - $this->resourceSelector = [ - 'rootResourceId' => $rootResourceId, - 'rootResourceType' => $rootResourceType, - 'rootResourceChildId' => $rootResourceChildId, - ]; + $this->resourceSelector = new ResourceSelector( + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, + ); try { - $this->run($resources, $callback, $rootResourceId, $rootResourceType); + $this->run( + $resources, + $callback, + $this->resourceSelector->getScopeId(), + $this->resourceSelector->getScopeType(), + ); } finally { $this->resourceSelector = $previousResourceSelector; } diff --git a/src/Migration/ResourceSelector.php b/src/Migration/ResourceSelector.php new file mode 100644 index 00000000..ddb27f5c --- /dev/null +++ b/src/Migration/ResourceSelector.php @@ -0,0 +1,27 @@ +parentResourceId ?: $this->resourceId; + } + + public function getScopeType(): string + { + return $this->parentResourceType ?: $this->resourceType; + } + +} diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 0a2897c5..837c23aa 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -126,7 +126,7 @@ public function run(array $resources, callable $callback, string $rootResourceId } /** - * Transfer resources using separate, opaque root and child IDs. + * Transfer resources using Appwrite's canonical resource relation fields. * * @param array $resources Resources to transfer * @param callable $callback Callback to run after transfer @@ -134,11 +134,23 @@ public function run(array $resources, callable $callback, string $rootResourceId public function runWithResourceSelector( array $resources, callable $callback, - string $rootResourceId, - string $rootResourceType, - string $rootResourceChildId, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, ): void { - $this->run($resources, $callback, $rootResourceId, $rootResourceType); + $selector = new ResourceSelector( + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, + ); + + $this->run($resources, $callback, $selector->getScopeId(), $selector->getScopeType()); } /** diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index f7e9d86f..25996248 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -215,20 +215,26 @@ public function run( public function runWithResourceSelector( array $resources, callable $callback, - string $rootResourceId, - string $rootResourceType, - string $rootResourceChildId, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, ): void { $previousResourceChildId = $this->resourceChildId; - $this->resourceChildId = $rootResourceChildId; + $this->resourceChildId = $parentResourceId !== '' ? $resourceId : ''; try { parent::runWithResourceSelector( $resources, $callback, - $rootResourceId, - $rootResourceType, - $rootResourceChildId, + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, ); } finally { $this->resourceChildId = $previousResourceChildId; diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 178a5cbb..833f18dd 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -216,10 +216,7 @@ class Transfer */ protected array $resources = []; - /** - * @var array{rootResourceId: string, rootResourceType: string, rootResourceChildId: string}|null - */ - private ?array $resourceSelector = null; + private ?ResourceSelector $resourceSelector = null; public function __construct(Source $source, Destination $destination) { @@ -377,9 +374,12 @@ public function run( $this->destination->runWithResourceSelector( $computedResources, $callback, - $this->resourceSelector['rootResourceId'], - $this->resourceSelector['rootResourceType'], - $this->resourceSelector['rootResourceChildId'], + $this->resourceSelector->resourceId, + $this->resourceSelector->resourceInternalId, + $this->resourceSelector->resourceType, + $this->resourceSelector->parentResourceId, + $this->resourceSelector->parentResourceInternalId, + $this->resourceSelector->parentResourceType, ); return; @@ -389,7 +389,7 @@ public function run( } /** - * Transfer resources using separate, opaque root and child IDs. + * Transfer resources using Appwrite's canonical resource relation fields. * * @param array> $resources Resources to transfer * @param callable $callback Callback to run after transfer @@ -398,19 +398,30 @@ public function run( public function runWithResourceSelector( array $resources, callable $callback, - string $rootResourceId, - string $rootResourceType, - string $rootResourceChildId, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, ): void { $previousResourceSelector = $this->resourceSelector; - $this->resourceSelector = [ - 'rootResourceId' => $rootResourceId, - 'rootResourceType' => $rootResourceType, - 'rootResourceChildId' => $rootResourceChildId, - ]; + $this->resourceSelector = new ResourceSelector( + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, + ); try { - $this->run($resources, $callback, $rootResourceId, $rootResourceType); + $this->run( + $resources, + $callback, + $this->resourceSelector->getScopeId(), + $this->resourceSelector->getScopeType(), + ); } finally { $this->resourceSelector = $previousResourceSelector; } diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 50822a74..bd62b3ce 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -45,20 +45,26 @@ public function run( public function runWithResourceSelector( array $resources, callable $callback, - string $rootResourceId, - string $rootResourceType, - string $rootResourceChildId, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, ): void { $previousResourceChildId = $this->resourceChildId; - $this->resourceChildId = $rootResourceChildId; + $this->resourceChildId = $parentResourceId !== '' ? $resourceId : ''; try { parent::runWithResourceSelector( $resources, $callback, - $rootResourceId, - $rootResourceType, - $rootResourceChildId, + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, ); } finally { $this->resourceChildId = $previousResourceChildId; diff --git a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php index 3c8f6dbc..1e125acd 100644 --- a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php +++ b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php @@ -149,9 +149,12 @@ static function () use ($explicit, $transfer): void { [Resource::TYPE_DATABASE], static function (): void { }, - 'database', - Resource::TYPE_DATABASE, - 'table', + resourceId: 'database', + resourceInternalId: '1', + resourceType: Resource::TYPE_DATABASE, + parentResourceId: '', + parentResourceInternalId: '', + parentResourceType: '', ); return; diff --git a/tests/Migration/Unit/General/TransferTest.php b/tests/Migration/Unit/General/TransferTest.php index c49c3edf..334c64c7 100644 --- a/tests/Migration/Unit/General/TransferTest.php +++ b/tests/Migration/Unit/General/TransferTest.php @@ -136,9 +136,12 @@ public function testExplicitSelectorKeepsColonContainingIdsOpaque(): void [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], static function (): void { }, - $database->getId(), - Resource::TYPE_DATABASE, - $second->getId(), + resourceId: $second->getId(), + resourceInternalId: '2', + resourceType: Resource::TYPE_TABLE, + parentResourceId: $database->getId(), + parentResourceInternalId: '1', + parentResourceType: Resource::TYPE_DATABASE, ); $this->assertSame( @@ -160,12 +163,46 @@ public function testExplicitSelectorDispatchesLegacyOverridesExactlyOnce(): void $source = new class () extends MockSource { public int $runCount = 0; + /** @var array{resourceId: string, resourceInternalId: string, resourceType: string, parentResourceId: string, parentResourceInternalId: string, parentResourceType: string}|null */ + public ?array $selector = null; + #[Override] public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void { $this->runCount++; parent::run($resources, $callback, $rootResourceId, $rootResourceType); } + + #[Override] + public function runWithResourceSelector( + array $resources, + callable $callback, + string $resourceId, + string $resourceInternalId, + string $resourceType, + string $parentResourceId, + string $parentResourceInternalId, + string $parentResourceType, + ): void { + $this->selector = [ + 'resourceId' => $resourceId, + 'resourceInternalId' => $resourceInternalId, + 'resourceType' => $resourceType, + 'parentResourceId' => $parentResourceId, + 'parentResourceInternalId' => $parentResourceInternalId, + 'parentResourceType' => $parentResourceType, + ]; + parent::runWithResourceSelector( + $resources, + $callback, + $resourceId, + $resourceInternalId, + $resourceType, + $parentResourceId, + $parentResourceInternalId, + $parentResourceType, + ); + } }; $destination = new class () extends MockDestination { public int $runCount = 0; @@ -201,14 +238,25 @@ public function run( [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], static function (): void { }, - $database->getId(), - Resource::TYPE_DATABASE, - $table->getId(), + resourceId: $table->getId(), + resourceInternalId: '2', + resourceType: Resource::TYPE_TABLE, + parentResourceId: $database->getId(), + parentResourceInternalId: '1', + parentResourceType: Resource::TYPE_DATABASE, ); $this->assertSame(1, $transfer->runCount); $this->assertSame(1, $destination->runCount); $this->assertSame(1, $source->runCount); + $this->assertSame([ + 'resourceId' => 'table', + 'resourceInternalId' => '2', + 'resourceType' => Resource::TYPE_TABLE, + 'parentResourceId' => 'database', + 'parentResourceInternalId' => '1', + 'parentResourceType' => Resource::TYPE_DATABASE, + ], $source->selector); } public function testExplicitSelectorStateIsRestoredAfterSuccess(): void @@ -226,9 +274,12 @@ public function testExplicitSelectorStateIsRestoredAfterSuccess(): void [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], static function (): void { }, - $selectedDatabase->getId(), - Resource::TYPE_DATABASE, - $selectedTable->getId(), + resourceId: $selectedTable->getId(), + resourceInternalId: '2', + resourceType: Resource::TYPE_TABLE, + parentResourceId: $selectedDatabase->getId(), + parentResourceInternalId: '1', + parentResourceType: Resource::TYPE_DATABASE, ); $this->transfer->run( [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], @@ -261,9 +312,12 @@ public function testExplicitSelectorStateIsRestoredAfterFailure(): void static function (): void { throw new \RuntimeException('stop'); }, - $selectedDatabase->getId(), - Resource::TYPE_DATABASE, - $selectedTable->getId(), + resourceId: $selectedTable->getId(), + resourceInternalId: '2', + resourceType: Resource::TYPE_TABLE, + parentResourceId: $selectedDatabase->getId(), + parentResourceInternalId: '1', + parentResourceType: Resource::TYPE_DATABASE, ); $this->fail('The callback exception should escape the transfer.'); } catch (\RuntimeException $error) {