From b1cc493c48fbc8446d00c4a4601cb15363f823a4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 18:38:50 +1300 Subject: [PATCH] fix(relationships): ignore relationships whose related collection was deleted 7.x deleteCollection() could leave relationship attributes behind on other collections, pointing at a collection whose metadata no longer exists. 7.x getCollection() returned an empty document for it, so document operations effectively skipped such a relationship. 8.x getCollection() throws NotFound, so every get, find, create, update, upsert and delete on a collection holding one fails with "Collection not found". The relationship hook now resolves a relationship's related collection with findCollection() and treats a missing one as a plain attribute: it is not related, populated, authorized, cascaded or restricted, and its stored value is returned as is. A write keeps the ID on the side holding the key and drops the value from a side that stores nothing. Population resolves the related collection once per relationship and passes it to the batch loaders, instead of reading it in each loader and again before queueing the next level. Co-Authored-By: Claude Opus 5.5 --- src/Database/Hook/Relationships.php | 149 +++++++--- src/Database/Trait/Documents.php | 6 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 2 + .../Scopes/Relationships/OrphanedTests.php | 272 ++++++++++++++++++ 4 files changed, 388 insertions(+), 41 deletions(-) create mode 100644 tests/e2e/Adapter/Scopes/Relationships/OrphanedTests.php diff --git a/src/Database/Hook/Relationships.php b/src/Database/Hook/Relationships.php index 63c0fa9a5..e037b0de0 100644 --- a/src/Database/Hook/Relationships.php +++ b/src/Database/Hook/Relationships.php @@ -592,7 +592,10 @@ private function collectCreated(PreparedCreate $prepared, Document $collection, continue; } - $relatedCollection = $this->collection($prepared, $relationship->relatedCollection); + $relatedCollection = $this->relatedCollection($relationship, $prepared); + if ($relatedCollection === null) { + continue; + } if ($depth >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$depth - 1] !== $relatedCollection->getId()) { continue; @@ -701,6 +704,57 @@ private function collection(PreparedCreate $prepared, string $id): Document return $prepared->collections[$id] ??= $this->database->getCollection($id); } + /** + * The collection a relationship relates to, or null when it was deleted while the relationship was left behind, + * as 7.x could leave it: such a relationship is not related, populated or cascaded, and keeps its stored value. + */ + private function relatedCollection(Relationship $relationship, ?PreparedCreate $prepared = null): ?Document + { + $id = $relationship->relatedCollection; + if ($prepared !== null && isset($prepared->collections[$id])) { + return $prepared->collections[$id]; + } + + $collection = $this->database->findCollection($id); + if ($prepared !== null && $collection !== null) { + $prepared->collections[$id] = $collection; + } + + return $collection; + } + + /** + * Keep the value written to a relationship whose related collection was deleted as the side holding its key + * stores it, by ID, and drop it from a side that stores nothing. + */ + private static function detach(Attribute $attribute, Document $document): void + { + $key = $attribute->key; + $relationship = $attribute->relationship; + if ($relationship === null) { + return; + } + + $parent = $attribute->side === RelationshipSide::Parent; + $storesKey = match ($relationship->type) { + RelationshipType::OneToOne => $parent || $relationship->twoWay, + RelationshipType::OneToMany => ! $parent, + RelationshipType::ManyToOne => $parent, + RelationshipType::ManyToMany => false, + }; + + if (! $storesKey) { + $document->removeAttribute($key); + + return; + } + + $value = $document->getAttribute($key); + if ($value instanceof Document) { + $document->setAttribute($key, $value->getId()); + } + } + /** * The attributes $collection declares, hydrated at most once per collection instance. * @@ -865,9 +919,12 @@ private function relateAttributes(Document $collection, Document $document, int if ($copies !== null && $stackCount === 0 && (\is_array($value) || $value instanceof Document)) { $this->copy($document, $copies); } - $relatedCollection = $prepared === null - ? $this->database->getCollection($relationship->relatedCollection) - : $this->collection($prepared, $relationship->relatedCollection); + $relatedCollection = $this->relatedCollection($relationship, $prepared); + if ($relatedCollection === null) { + self::detach($attribute, $document); + + continue; + } $relationType = $relationship->type; $twoWay = $relationship->twoWay; $twoWayKey = $relationship->twoWayKey ?? ''; @@ -1032,7 +1089,6 @@ public function afterDocumentUpdate(Document $collection, Document $old, Documen $oldValue = $old->getAttribute($key); $oldId = $oldValue instanceof Document ? $oldValue->getId() : (\is_string($oldValue) ? $oldValue : null); - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); $relationType = $relationship->type; $twoWay = $relationship->twoWay; $twoWayKey = $relationship->twoWayKey ?? ''; @@ -1067,6 +1123,13 @@ public function afterDocumentUpdate(Document $collection, Document $old, Documen continue; } + $relatedCollection = $this->relatedCollection($relationship); + if ($relatedCollection === null) { + self::detach($attribute, $document); + + continue; + } + if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$stackCount - 1] !== $relatedCollection->getId()) { $document->removeAttribute($key); @@ -1539,9 +1602,13 @@ public function beforeDocumentDelete(Document $collection, Document $document, b continue; } + $relatedCollection = $this->relatedCollection($relationship); + if ($relatedCollection === null) { + continue; + } + $key = $attribute->key; $value = $document->getAttribute($key); - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); $relationType = $relationship->type; $twoWay = $relationship->twoWay; $twoWayKey = $relationship->twoWayKey ?? ''; @@ -1730,9 +1797,15 @@ public function populateDocuments(array $documents, Document $collection, int $f continue; } + $relatedCollection = $this->relatedCollection($relationship); + if ($relatedCollection === null) { + continue; + } + $relatedDocuments = $this->populateSingleRelationshipBatch( $batchDocuments, $batchCollection, + $relatedCollection, $key, $relationship, $side, @@ -1747,25 +1820,20 @@ public function populateDocuments(array $documents, Document $collection, int $f ($hasNestedSelects || ! $parentHasExplicitSelects); if ($shouldQueue) { - $relatedCollectionId = $relationship->relatedCollection; - $relatedCollection = $this->database->silent(fn () => $this->database->findCollection($relatedCollectionId)); - - if ($relatedCollection !== null) { - $relationshipQueries = $hasNestedSelects ? $batchSelects[$key] : []; + $relationshipQueries = $hasNestedSelects ? $batchSelects[$key] : []; - $nextSelects = $this->processQueries(self::relationships($relatedCollection), $relationshipQueries); + $nextSelects = $this->processQueries(self::relationships($relatedCollection), $relationshipQueries); - $childHasExplicitSelects = $parentHasExplicitSelects || self::selects($relationshipQueries); + $childHasExplicitSelects = $parentHasExplicitSelects || self::selects($relationshipQueries); - $nextQueue[] = [ - 'documents' => $relatedDocuments, - 'collection' => $relatedCollection, - 'depth' => $currentDepth + 1, - 'selects' => $nextSelects, - 'skipKey' => $twoWay ? $twoWayKey : null, - 'hasExplicitSelects' => $childHasExplicitSelects, - ]; - } + $nextQueue[] = [ + 'documents' => $relatedDocuments, + 'collection' => $relatedCollection, + 'depth' => $currentDepth + 1, + 'selects' => $nextSelects, + 'skipKey' => $twoWay ? $twoWayKey : null, + 'hasExplicitSelects' => $childHasExplicitSelects, + ]; } if ($twoWay && ! empty($relatedDocuments)) { @@ -2413,13 +2481,13 @@ private function applyRelationshipOperator(Operator $operator, array $existingId * @param array $queries * @return array */ - private function populateSingleRelationshipBatch(array $documents, Document $collection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + private function populateSingleRelationshipBatch(array $documents, Document $collection, Document $relatedCollection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array { return match ($relationship->type) { - RelationshipType::OneToOne => $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries), - RelationshipType::OneToMany => $this->populateOneToManyRelationshipsBatch($documents, $key, $relationship, $side, $queries), - RelationshipType::ManyToOne => $this->populateManyToOneRelationshipsBatch($documents, $key, $relationship, $side, $queries), - RelationshipType::ManyToMany => $this->populateManyToManyRelationshipsBatch($documents, $collection, $key, $relationship, $side, $queries), + RelationshipType::OneToOne => $this->populateOneToOneRelationshipsBatch($documents, $relatedCollection, $key, $relationship, $queries), + RelationshipType::OneToMany => $this->populateOneToManyRelationshipsBatch($documents, $relatedCollection, $key, $relationship, $side, $queries), + RelationshipType::ManyToOne => $this->populateManyToOneRelationshipsBatch($documents, $relatedCollection, $key, $relationship, $side, $queries), + RelationshipType::ManyToMany => $this->populateManyToManyRelationshipsBatch($documents, $collection, $relatedCollection, $key, $relationship, $side, $queries), }; } @@ -2428,10 +2496,8 @@ private function populateSingleRelationshipBatch(array $documents, Document $col * @param array $queries * @return array */ - private function populateOneToOneRelationshipsBatch(array $documents, string $key, Relationship $relationship, array $queries): array + private function populateOneToOneRelationshipsBatch(array $documents, Document $relatedCollection, string $key, Relationship $relationship, array $queries): array { - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); - $relatedIds = []; $documentsByRelatedId = []; @@ -2505,10 +2571,9 @@ private function populateOneToOneRelationshipsBatch(array $documents, string $ke * @param array $queries * @return array */ - private function populateOneToManyRelationshipsBatch(array $documents, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + private function populateOneToManyRelationshipsBatch(array $documents, Document $relatedCollection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array { $twoWayKey = $relationship->twoWayKey ?? ''; - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); if ($side === RelationshipSide::Child) { if (! $relationship->twoWay) { @@ -2519,7 +2584,7 @@ private function populateOneToManyRelationshipsBatch(array $documents, string $k return []; } - return $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries); + return $this->populateOneToOneRelationshipsBatch($documents, $relatedCollection, $key, $relationship, $queries); } $parentIds = []; @@ -2583,13 +2648,12 @@ private function populateOneToManyRelationshipsBatch(array $documents, string $k * @param array $queries * @return array */ - private function populateManyToOneRelationshipsBatch(array $documents, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + private function populateManyToOneRelationshipsBatch(array $documents, Document $relatedCollection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array { $twoWayKey = $relationship->twoWayKey ?? ''; - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); if ($side === RelationshipSide::Parent) { - return $this->populateOneToOneRelationshipsBatch($documents, $key, $relationship, $queries); + return $this->populateOneToOneRelationshipsBatch($documents, $relatedCollection, $key, $relationship, $queries); } if (! $relationship->twoWay) { @@ -2661,10 +2725,9 @@ private function populateManyToOneRelationshipsBatch(array $documents, string $k * @param array $queries * @return array */ - private function populateManyToManyRelationshipsBatch(array $documents, Document $collection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array + private function populateManyToManyRelationshipsBatch(array $documents, Document $collection, Document $relatedCollection, string $key, Relationship $relationship, RelationshipSide $side, array $queries): array { $twoWayKey = $relationship->twoWayKey ?? ''; - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); if (! $relationship->twoWay && $side === RelationshipSide::Child) { return []; @@ -3389,7 +3452,10 @@ private function prepareNestedValues(Document $collection, Document $document, a continue; } - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + $relatedCollection = $this->relatedCollection($relationship); + if ($relatedCollection === null) { + continue; + } if ($depth >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$depth - 1] !== $relatedCollection->getId()) { continue; } @@ -3537,7 +3603,10 @@ private function authorizeRelated(Document $collection, Document $document, ?Doc continue; } - $relatedCollection = $this->database->getCollection($relationship->relatedCollection); + $relatedCollection = $this->relatedCollection($relationship); + if ($relatedCollection === null) { + continue; + } if ($depth >= Database::RELATION_MAX_DEPTH - 1 && $writeStack[$depth - 1] !== $relatedCollection->getId()) { continue; } diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 9d01213fe..6793dcc9a 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -5517,7 +5517,11 @@ private function validateRelationshipSelects(Collection $collection, array $quer continue; } - $related = $this->silent(fn () => $this->getCollection($relatedCollectionId)); + $related = $this->silent(fn () => $this->findCollection($relatedCollectionId)); + if ($related === null) { + continue; + } + $validator = new SelectValidator( [...$related->attributes(), ...DocumentsValidator::internalAttributes()], $this->adapter->supports(Capability::DefinedAttributes), diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index ad30ee21d..1030a234b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -12,6 +12,7 @@ use Tests\E2E\Adapter\Scopes\Relationships\NestedPreparationTests; use Tests\E2E\Adapter\Scopes\Relationships\OneToManyTests; use Tests\E2E\Adapter\Scopes\Relationships\OneToOneTests; +use Tests\E2E\Adapter\Scopes\Relationships\OrphanedTests; use Tests\E2E\Adapter\Support\EventRecorder; use Throwable; use Utopia\Database\Adapter\Feature; @@ -46,6 +47,7 @@ trait RelationshipTests use NestedPreparationTests; use OneToManyTests; use OneToOneTests; + use OrphanedTests; /** * @return array diff --git a/tests/e2e/Adapter/Scopes/Relationships/OrphanedTests.php b/tests/e2e/Adapter/Scopes/Relationships/OrphanedTests.php new file mode 100644 index 000000000..b8d991446 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/Relationships/OrphanedTests.php @@ -0,0 +1,272 @@ +getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $updated = $database->updateDocument($articles, 'article1', new Document(['name' => 'updated'])); + $this->assertSame('updated', $updated->getAttribute('name'), 'updateDocument must succeed past relationships to deleted collections'); + $article = $database->getDocument($articles, 'article1'); + $this->assertSame('updated', $article->getAttribute('name')); + $this->assertSame('author1', $article->getAttribute('author'), 'updateDocument must leave an untouched orphaned relationship as stored'); + $this->assertSame('editor1', $article->getAttribute('editor')); + + $updated = $database->updateDocument($articles, 'article1', $article->setAttribute('name', 'whole')); + $this->assertSame('whole', $updated->getAttribute('name'), 'updateDocument must take back the whole document it read'); + $this->assertSame('author1', $database->getDocument($articles, 'article1')->getAttribute('author')); + + $updated = $database->updateDocument($articles, 'article1', new Document([ + 'name' => 'linked', + 'comments' => ['comment2'], + 'tags' => ['tag2'], + 'topics' => ['topic2'], + ])); + $this->assertSame('linked', $updated->getAttribute('name'), 'updateDocument must drop a write to an orphaned relationship that stores nothing'); + + $updated = $database->updateDocument($articles, 'article1', new Document(['author' => 'author2'])); + $this->assertSame('author2', $updated->getAttribute('author'), 'A write to an orphaned relationship stores the raw ID'); + $this->assertSame('author2', $database->getDocument($articles, 'article1')->getAttribute('author')); + + $updated = $database->updateDocument($articles, 'article1', new Document(['author' => null])); + $this->assertNull($updated->getAttribute('author')); + $this->assertNull($database->getDocument($articles, 'article1')->getAttribute('author')); + } + + public function testOrphanedRelationshipCreateDocument(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $created = $database->createDocument($articles, new Document([ + '$id' => 'article3', + '$permissions' => self::orphanedPermissions(), + 'name' => 'third', + ])); + $this->assertSame('third', $created->getAttribute('name'), 'createDocument must succeed past relationships to deleted collections'); + + $created = $database->createDocument($articles, new Document([ + '$id' => 'article4', + '$permissions' => self::orphanedPermissions(), + 'name' => 'fourth', + 'author' => 'author1', + 'comments' => ['comment2'], + 'tags' => ['tag2'], + 'topics' => ['topic2'], + ])); + $this->assertSame('author1', $created->getAttribute('author'), 'createDocument stores the raw ID of an orphaned relationship'); + $this->assertSame('author1', $database->getDocument($articles, 'article4')->getAttribute('author')); + } + + public function testOrphanedRelationshipGetDocument(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $article = $database->getDocument($articles, 'article1'); + $this->assertSame('first', $article->getAttribute('name'), 'getDocument must read a document whose relationships point at deleted collections'); + $this->assertSame('author1', $article->getAttribute('author'), 'An orphaned relationship keeps its stored value unpopulated'); + $this->assertSame('editor1', $article->getAttribute('editor'), 'An orphaned relationship keeps its stored value unpopulated'); + + $article = $database->getDocument($articles, 'article1', [Query::select(['name', 'author.*'])]); + $this->assertSame('first', $article->getAttribute('name'), 'getDocument selecting an orphaned relationship must read the document'); + $this->assertSame('author1', $article->getAttribute('author')); + } + + public function testOrphanedRelationshipFind(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $ids = static fn (array $documents): array => \array_map(static fn (mixed $document): string => $document instanceof Document ? $document->getId() : '', $documents); + + $found = $database->find($articles, [Query::orderAsc('$id')]); + $this->assertSame(['article1', 'article2'], $ids($found), 'find must list documents whose relationships point at deleted collections'); + $this->assertSame('author1', $found[0]->getAttribute('author')); + + $found = $database->find($articles, [Query::select(['name']), Query::orderAsc('$id')]); + $this->assertSame(['article1', 'article2'], $ids($found), 'find with a select must list documents whose relationships point at deleted collections'); + $this->assertSame('first', $found[0]->getAttribute('name')); + + $found = $database->find($articles, [Query::select(['name', 'author.*']), Query::orderAsc('$id')]); + $this->assertSame(['article1', 'article2'], $ids($found), 'find selecting an orphaned relationship must list its documents'); + $this->assertSame('author1', $found[0]->getAttribute('author'), 'A selected orphaned relationship returns its stored value'); + + $found = $database->find($articles, [Query::select(['*', 'author.name', 'comments.name', 'tags.name', 'topics.name']), Query::orderAsc('$id')]); + $this->assertSame(['article1', 'article2'], $ids($found), 'find selecting through orphaned relationships must list their documents'); + $this->assertSame('author1', $found[0]->getAttribute('author')); + + $found = $database->find($articles, [Query::equal('author', ['author1']), Query::orderAsc('$id')]); + $this->assertSame(['article1', 'article2'], $ids($found), 'find must filter on the stored value of an orphaned relationship'); + } + + public function testOrphanedRelationshipDeleteDocument(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $this->assertTrue($database->deleteDocument($articles, 'article1'), 'deleteDocument must ignore onDelete actions of relationships to deleted collections'); + $this->assertTrue($database->getDocument($articles, 'article1')->isEmpty()); + + $this->assertSame(1, $database->deleteDocuments($articles), 'deleteDocuments must ignore onDelete actions of relationships to deleted collections'); + $this->assertSame([], $database->find($articles)); + } + + public function testOrphanedRelationshipUpsertDocument(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + if (! $database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $upserted = $database->upsertDocument($articles, new Document([ + '$id' => 'article2', + '$permissions' => self::orphanedPermissions(), + 'name' => 'upserted', + 'author' => 'author1', + 'editor' => 'editor1', + ])); + $this->assertSame('upserted', $upserted->getAttribute('name'), 'upsertDocument must update a document whose relationships point at deleted collections'); + $this->assertSame('upserted', $database->getDocument($articles, 'article2')->getAttribute('name')); + + $upserted = $database->upsertDocument($articles, new Document([ + '$id' => 'article5', + '$permissions' => self::orphanedPermissions(), + 'name' => 'fifth', + 'author' => 'author2', + ])); + $this->assertSame('fifth', $upserted->getAttribute('name'), 'upsertDocument must create a document whose relationships point at deleted collections'); + $this->assertSame('author2', $database->getDocument($articles, 'article5')->getAttribute('author')); + } + + public function testOrphanedRelationshipUpdateDocuments(): void + { + $database = $this->getDatabase(); + $articles = $this->orphanedArticles($database); + if ($articles === null) { + return; + } + + $count = $database->updateDocuments($articles, new Document(['name' => 'bulk'])); + $this->assertSame(2, $count, 'updateDocuments must update documents whose relationships point at deleted collections'); + foreach ($database->find($articles) as $document) { + $this->assertSame('bulk', $document->getAttribute('name')); + $this->assertSame('author1', $document->getAttribute('author')); + } + } + + /** + * A collection of articles relating to collections since deleted, as 7.x could leave them: one-way many-to-one + * relationships it holds (onDelete setNull and restrict), the child sides of one-way many-to-one relationships + * other collections held on it, a one-to-many and a many-to-many. Null when the adapter has no relationships. + */ + private function orphanedArticles(Database $database): ?string + { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return null; + } + + $suffix = Id::unique(); + $articles = 'orphanArticles'.$suffix; + $authors = 'orphanAuthors'.$suffix; + $editors = 'orphanEditors'.$suffix; + $comments = 'orphanComments'.$suffix; + $likes = 'orphanLikes'.$suffix; + $tags = 'orphanTags'.$suffix; + $topics = 'orphanTopics'.$suffix; + $permissions = self::orphanedPermissions(); + + foreach ([$articles, $authors, $editors, $comments, $likes, $tags, $topics] as $collection) { + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $permissions, + )); + } + + $database->createRelationship($articles, Relationship::manyToOne(relatedCollection: $authors, key: 'author', twoWayKey: 'articles', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship($articles, Relationship::manyToOne(relatedCollection: $editors, key: 'editor', twoWayKey: 'articles', onDelete: RelationshipDeleteAction::Restrict)); + $database->createRelationship($comments, Relationship::manyToOne(relatedCollection: $articles, key: 'article', twoWayKey: 'comments', onDelete: RelationshipDeleteAction::SetNull)); + $database->createRelationship($likes, Relationship::manyToOne(relatedCollection: $articles, key: 'article', twoWayKey: 'likes', onDelete: RelationshipDeleteAction::Restrict)); + $database->createRelationship($articles, Relationship::oneToMany(relatedCollection: $tags, key: 'tags', twoWayKey: 'article', onDelete: RelationshipDeleteAction::Cascade)); + $database->createRelationship($articles, Relationship::manyToMany(relatedCollection: $topics, key: 'topics', twoWayKey: 'articles', onDelete: RelationshipDeleteAction::Restrict)); + + $database->createDocument($authors, new Document(['$id' => 'author1', '$permissions' => $permissions, 'name' => 'author'])); + $database->createDocument($editors, new Document(['$id' => 'editor1', '$permissions' => $permissions, 'name' => 'editor'])); + $database->createDocument($topics, new Document(['$id' => 'topic1', '$permissions' => $permissions, 'name' => 'topic'])); + foreach (['article1' => 'first', 'article2' => 'second'] as $id => $name) { + $database->createDocument($articles, new Document([ + '$id' => $id, + '$permissions' => $permissions, + 'name' => $name, + 'author' => 'author1', + 'editor' => 'editor1', + ])); + } + $database->createDocument($comments, new Document(['$id' => 'comment1', '$permissions' => $permissions, 'name' => 'comment', 'article' => 'article1'])); + $database->createDocument($likes, new Document(['$id' => 'like1', '$permissions' => $permissions, 'name' => 'like', 'article' => 'article1'])); + $database->createDocument($tags, new Document(['$id' => 'tag1', '$permissions' => $permissions, 'name' => 'tag', 'article' => 'article1'])); + $database->updateDocument($articles, 'article1', new Document(['topics' => ['topic1']])); + + foreach ([$authors, $editors, $comments, $likes, $tags, $topics] as $collection) { + $database->getAdapter()->deleteCollection($collection); + $database->getAuthorization()->skip(fn (): bool => $database->silent(fn (): bool => $database->deleteDocument(Database::METADATA, $collection))); + $database->purgeCachedCollection($collection); + } + + return $articles; + } + + /** + * @return list + */ + private static function orphanedPermissions(): array + { + return [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } +}