diff --git a/src/Migration/Destination.php b/src/Migration/Destination.php index f3206533..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 */ @@ -34,14 +39,50 @@ public function run( string $rootResourceId = '', string $rootResourceType = '', ): void { - $this->source->run( - $resources, - function (array $resources) use ($callback) { - $this->import($resources, $callback); - }, - $rootResourceId, - $rootResourceType, - ); + $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 b45f3077..e1b31eb3 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/src/Migration/Destinations/CSV.php b/src/Migration/Destinations/CSV.php index 790e6a0a..f5b7c603 100644 --- a/src/Migration/Destinations/CSV.php +++ b/src/Migration/Destinations/CSV.php @@ -18,6 +18,7 @@ class CSV extends Destination { protected Device $deviceForFiles; protected string $resourceId; + private ?string $resourceChildId = null; protected string $directory; protected string $outputFile; protected Local $local; @@ -54,6 +55,34 @@ 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 { + $destination = new self( + deviceForFiles: $deviceForFiles, + resourceId: $databaseId, + directory: $directory, + filename: $filename, + allowedColumns: $allowedColumns, + delimiter: $delimiter, + enclosure: $enclosure, + escape: $escape, + includeHeaders: $includeHeaders, + ); + $destination->resourceChildId = $tableId; + + return $destination; + } + public static function getName(): string { return 'CSV'; @@ -193,7 +222,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->resourceId + $this->resourceChildId ?? $this->resourceId )); } } diff --git a/src/Migration/Destinations/JSON.php b/src/Migration/Destinations/JSON.php index 6660ab5a..12425742 100644 --- a/src/Migration/Destinations/JSON.php +++ b/src/Migration/Destinations/JSON.php @@ -19,6 +19,7 @@ class JSON extends Destination { protected Device $deviceForFiles; protected string $resourceId; + private ?string $resourceChildId = null; protected string $directory; protected string $outputFile; protected Local $local; @@ -56,6 +57,26 @@ public function __construct( } } + public static function fromResourceIds( + Device $deviceForFiles, + string $databaseId, + string $tableId, + string $directory, + string $filename, + array $allowedColumns = [], + ): self { + $destination = new self( + deviceForFiles: $deviceForFiles, + resourceId: $databaseId, + directory: $directory, + filename: $filename, + allowedColumns: $allowedColumns, + ); + $destination->resourceChildId = $tableId; + + return $destination; + } + public static function getName(): string { return 'JSON'; @@ -217,7 +238,7 @@ public function shutdown(): void UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, 'Error cleaning up: ' . $this->local->getRoot(), - $this->resourceId + $this->resourceChildId ?? $this->resourceId )); } } diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 9c126c02..0a2897c5 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -89,6 +89,8 @@ public function callback(array $resources): void */ public function run(array $resources, callable $callback, string $rootResourceId = '', string $rootResourceType = ''): void { + $previousRootResourceId = $this->rootResourceId; + $previousRootResourceType = $this->rootResourceType; $this->rootResourceId = $rootResourceId; $this->rootResourceType = $rootResourceType; @@ -115,7 +117,28 @@ 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; + } + } + + /** + * 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 { + $this->run($resources, $callback, $rootResourceId, $rootResourceType); } /** diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 40ec3dee..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; @@ -1073,18 +1128,8 @@ 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)) { - $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]); + 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); } @@ -1148,24 +1193,20 @@ 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 or collection when its database root has a child set, + // or when the root itself is a table. if ( $this->rootResourceId !== '' && - $this->rootResourceType === Resource::TYPE_DATABASE && - \str_contains($this->rootResourceId, ':') + $this->resourceChildId !== '' && + $this->rootResourceType === $databaseName ) { - $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->resourceChildId]); + $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..38424d4c 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -28,10 +28,12 @@ class CSV extends Source private string $filePath; /** - * format: `{databaseId:tableId}` + * Legacy format: `{databaseId}:{tableId}`. */ private string $resourceId; + private ?string $resourceChildId = null; + private Device $device; private Reader $database; @@ -51,6 +53,26 @@ public function __construct( $this->database = new DatabaseReader($dbForProject, $getDatabasesDB); } + public static function fromResourceIds( + string $databaseId, + string $tableId, + string $filePath, + Device $device, + ?UtopiaDatabase $dbForProject, + ?callable $getDatabasesDB = null, + ): self { + $source = new self( + resourceId: $databaseId, + filePath: $filePath, + device: $device, + dbForProject: $dbForProject, + getDatabasesDB: $getDatabasesDB, + ); + $source->resourceChildId = $tableId; + + return $source; + } + public static function getName(): string { return 'CSV'; @@ -130,8 +152,7 @@ private function exportRows(int $batchSize): void { $columns = []; $lastColumn = null; - - [$databaseId, $tableId] = \explode(':', $this->resourceId); + [$databaseId, $tableId] = $this->getResourceIds(); $databases = $this->database->listDatabases([ $this->database->queryEqual('$id', [$databaseId]), @@ -520,11 +541,25 @@ private function validateCSVHeaders(array $headers, array $columnTypes, array $r UtopiaResource::TYPE_ROW, Transfer::GROUP_DATABASES, \implode(', ', $messages), - $this->resourceId + $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 46124a09..e4613d66 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -23,10 +23,12 @@ class JSON extends Source private string $filePath; /** - * format: `{databaseId:tableId}` + * Legacy format: `{databaseId}:{tableId}`. */ private string $resourceId; + private ?string $resourceChildId = null; + private Device $device; /** @noinspection PhpPropertyOnlyWrittenInspection */ @@ -38,7 +40,7 @@ public function __construct( string $resourceId, string $filePath, Device $device, - ?UtopiaDatabase $dbForProject + ?UtopiaDatabase $dbForProject, ) { $this->device = $device; $this->filePath = $filePath; @@ -48,6 +50,24 @@ public function __construct( $this->dbForProject = $dbForProject; } + public static function fromResourceIds( + string $databaseId, + string $tableId, + string $filePath, + Device $device, + ?UtopiaDatabase $dbForProject, + ): self { + $source = new self( + resourceId: $databaseId, + filePath: $filePath, + device: $device, + dbForProject: $dbForProject, + ); + $source->resourceChildId = $tableId; + + return $source; + } + public static function getName(): string { return 'JSON'; @@ -120,7 +140,7 @@ protected function exportGroupDatabases(int $batchSize, array $resources): void */ private function exportRows(int $batchSize): void { - [$databaseId, $tableId] = \explode(':', $this->resourceId); + [$databaseId, $tableId] = $this->getResourceIds(); $database = new Database($databaseId, ''); $table = new Table($database, '', $tableId); @@ -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/Transfer.php b/src/Migration/Transfer.php index 267e4289..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; @@ -368,12 +373,47 @@ public function run( $this->resources = $computedResources; - $this->destination->run( - $computedResources, - $callback, - $rootResourceId, - $rootResourceType, - ); + 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/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index cbdf9602..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)) { @@ -50,6 +105,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->resourceChildId !== '') { + $this->callback([$this->getMockResourceById($group, $type, $this->resourceChildId)]); + return; + } + $resources = $this->getMockResourcesByType($group, $type) ?? []; $this->callback($resources); return; diff --git a/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php new file mode 100644 index 00000000..3c8f6dbc --- /dev/null +++ b/tests/Migration/Unit/Destinations/AppwriteDatabaseStatusTest.php @@ -0,0 +1,188 @@ +runCount++; + parent::run($resources, $callback, $rootResourceId, $rootResourceType); + } +} + +final class AppwriteDatabaseStatusTest extends TestCase +{ + public function testDatabaseCreationOmitsStatusThroughLegacyAndExplicitEntrypoints(): void + { + foreach ([false, true] as $explicit) { + $database = $this->createProjectDatabase(withStatus: false); + + $destination = $this->runDatabaseTransfer($database, $explicit); + + $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 testDatabaseCreationPreservesLifecycleThroughLegacyAndExplicitEntrypoints(): void + { + foreach ([false, true] as $explicit) { + $database = $this->createProjectDatabase(withStatus: true); + + $destination = $this->runDatabaseTransfer($database, $explicit); + + $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 + { + $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, bool $explicit): CountingAppwriteDestination + { + $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 CountingAppwriteDestination( + 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 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; + } + + 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(), + ); + } +} diff --git a/tests/Migration/Unit/General/CSVTest.php b/tests/Migration/Unit/General/CSVTest.php index e4fd3ec1..837216ce 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,105 @@ 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 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(); + $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); + } + + 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 */ @@ -46,9 +151,6 @@ private function detectDelimiter($stream): string $refMethod = $reflection->getMethod('delimiter'); - /** @noinspection PhpExpressionResultUnusedInspection */ - $refMethod->setAccessible(true); - return $refMethod->invoke($instance, $stream); } @@ -434,4 +536,40 @@ 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); + } + + /** + * @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( + new MemoryAdapter(), + new Cache(new MemoryCache()), + ); + } } diff --git a/tests/Migration/Unit/General/JSONTest.php b/tests/Migration/Unit/General/JSONTest.php index 1a880ec7..ca32ccdb 100644 --- a/tests/Migration/Unit/General/JSONTest.php +++ b/tests/Migration/Unit/General/JSONTest.php @@ -8,12 +8,133 @@ 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 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(); + $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 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(); + \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(); @@ -288,4 +409,40 @@ 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); + } + + /** + * @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()); + } } 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 e4d95c1a..c49c3edf 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,6 +68,222 @@ function () { $this->assertSame('test', $database->getId()); } + public function testLegacyCompoundRootResourceIdScopesDatabaseEntity(): 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() . ':' . $second->getId(), + Resource::TYPE_DATABASE, + ); + + $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')); + } + + 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'); + $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