diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a8eb59d0..0334d171 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -2896,6 +2896,18 @@ protected function createProvider(Provider $resource): void ($options['replyToEmail'] ?? '') ?: null, $enabled, ), + 'ses' => $this->messaging->createSesProvider( + $id, + $name, + $credentials['accessKey'] ?? null, + $credentials['secretKey'] ?? null, + $credentials['region'] ?? null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), 'smtp' => $this->messaging->createSMTPProvider( $id, $name, @@ -3158,7 +3170,7 @@ protected function createScheduledMessage(Message $resource, array $resolvedTarg $targets, $data['cc'] ?? null, $data['bcc'] ?? null, - null, + $data['attachments'] ?? null, false, $data['html'] ?? null, $scheduledAt, diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 837c23aa..336035ba 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -21,6 +21,17 @@ public function supportsDatabaseStatus(): bool return false; } + /** + * Resources this source cannot transfer unless their prerequisites travel + * with them, keyed by resource type. + * + * @return array> + */ + public function getResourceDependencies(): array + { + return []; + } + public function getAuthBatchSize(): int { return static::$defaultBatchSize; @@ -160,8 +171,9 @@ public function runWithResourceSelector( */ public function exportResources(array $resources): void { + $requested = $resources; $groups = []; - foreach ($resources as $resource) { + foreach ($requested as $resource) { $mapping = [ Transfer::GROUP_AUTH => Transfer::GROUP_AUTH_RESOURCES, Transfer::GROUP_DATABASES => Transfer::GROUP_DATABASES_RESOURCES, @@ -187,6 +199,8 @@ public function exportResources(array $resources): void return; } + $this->reportMissingDependencies($requested, $groups); + foreach ($groups as $group => $resources) { switch ($group) { case Transfer::GROUP_AUTH: @@ -223,6 +237,51 @@ public function exportResources(array $resources): void } } + /** + * Record an error for every requested resource whose prerequisites are absent. + * + * A resource requested without its prerequisites is not an error on its own: + * its exporter walks a cache the missing prerequisite never filled, or is + * only reached by the prerequisite's own exporter. Neither path raises + * anything, so the transfer finishes reporting success having moved none of + * it. Naming what is absent turns that into a failure someone can act on. + * + * @param array $requested + * @param array> $groups + */ + private function reportMissingDependencies(array $requested, array $groups): void + { + $groupOf = []; + foreach ($groups as $group => $resources) { + foreach ($resources as $resource) { + $groupOf[$resource] = $group; + } + } + + foreach ($this->getResourceDependencies() as $resource => $requires) { + if (!\in_array($resource, $requested, true)) { + continue; + } + + $missing = \array_values(\array_diff($requires, $requested)); + + if (empty($missing)) { + continue; + } + + $this->addError(new Exception( + $resource, + $groupOf[$resource] ?? Transfer::GROUP_GENERAL, + message: \sprintf( + 'Cannot transfer %s without %s.', + $resource, + \implode(' and ', $missing) + ), + code: Exception::CODE_VALIDATION, + )); + } + } + /** * Export Auth Group * diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 61443459..cd521857 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -323,6 +323,35 @@ public static function getSupportedResources(): array ]; } + /** + * Prerequisites each resource needs in the same request. Every exporter here + * either walks its parent out of the transfer cache, or is emitted inline by + * the parent's own exporter, so a request naming the child alone moves + * nothing at all. + * + * Index, collection, attribute and document are deliberately absent. They + * are shared between the tables, documents and vectors flavours, whose + * parents differ, and naming one flavour's parents would reject the others. + * + * @return array> + */ + #[Override] + public function getResourceDependencies(): array + { + return [ + Resource::TYPE_MEMBERSHIP => [Resource::TYPE_USER, Resource::TYPE_TEAM], + Resource::TYPE_SUBSCRIBER => [Resource::TYPE_TOPIC, Resource::TYPE_USER], + Resource::TYPE_TABLE => [Resource::TYPE_DATABASE], + Resource::TYPE_COLUMN => [Resource::TYPE_DATABASE, Resource::TYPE_TABLE], + Resource::TYPE_ROW => [Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN], + Resource::TYPE_FILE => [Resource::TYPE_BUCKET], + Resource::TYPE_ENVIRONMENT_VARIABLE => [Resource::TYPE_FUNCTION], + Resource::TYPE_DEPLOYMENT => [Resource::TYPE_FUNCTION], + Resource::TYPE_SITE_VARIABLE => [Resource::TYPE_SITE], + Resource::TYPE_SITE_DEPLOYMENT => [Resource::TYPE_SITE], + ]; + } + /** * @return int */ diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index bd62b3ce..63c0509b 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -11,8 +11,21 @@ class MockSource extends Source { private array $mockResources = []; + private array $resourceDependencies = []; + private ?string $resourceChildId = null; + public function setResourceDependencies(array $dependencies): void + { + $this->resourceDependencies = $dependencies; + } + + #[Override] + public function getResourceDependencies(): array + { + return $this->resourceDependencies; + } + #[Override] public function run( array $resources, diff --git a/tests/Migration/Unit/General/ResourceDependenciesTest.php b/tests/Migration/Unit/General/ResourceDependenciesTest.php new file mode 100644 index 00000000..08023bef --- /dev/null +++ b/tests/Migration/Unit/General/ResourceDependenciesTest.php @@ -0,0 +1,100 @@ +source = new MockSource(); + $this->destination = new MockDestination(); + + $this->transfer = new Transfer( + $this->source, + $this->destination + ); + + $this->source->setResourceDependencies([ + Resource::TYPE_MEMBERSHIP => [Resource::TYPE_USER, Resource::TYPE_TEAM], + ]); + + $team = new Team('team', 'Team'); + $user = new User('user', 'user@example.com'); + + $this->source->pushMockResource($team); + $this->source->pushMockResource($user); + $this->source->pushMockResource(new Membership('membership', $team, $user)); + } + + public function testMissingPrerequisitesAreReported(): void + { + $this->transfer->run([Resource::TYPE_MEMBERSHIP], function () { + }); + + $errors = $this->source->getErrors(); + + $this->assertCount(1, $errors); + $this->assertSame(Resource::TYPE_MEMBERSHIP, $errors[0]->getResourceName()); + $this->assertSame( + 'Cannot transfer membership without user and team.', + $errors[0]->getMessage() + ); + } + + public function testOnlyTheAbsentPrerequisitesAreNamed(): void + { + $this->transfer->run( + [Resource::TYPE_USER, Resource::TYPE_MEMBERSHIP], + function () { + } + ); + + $errors = $this->source->getErrors(); + + $this->assertCount(1, $errors); + $this->assertSame( + 'Cannot transfer membership without team.', + $errors[0]->getMessage() + ); + } + + public function testASatisfiedRequestIsNotReported(): void + { + $this->transfer->run( + [Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP], + function () { + } + ); + + $this->assertEmpty($this->source->getErrors()); + } + + public function testAResourceWithNoPrerequisitesIsNotReported(): void + { + $this->transfer->run([Resource::TYPE_USER], function () { + }); + + $this->assertEmpty($this->source->getErrors()); + } +}