diff --git a/src/Database/Database.php b/src/Database/Database.php index e9bd5b1b6..0093663f3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -273,9 +273,14 @@ class Database private array $relationshipWriteStack = []; /** - * @var array> + * @var array + */ + private array $relationshipFetchStack = []; + + /** + * @var array */ - private array $relationshipFetchMap = []; + private array $relationshipDeleteStack = []; /** * @param Adapter $adapter @@ -2237,11 +2242,9 @@ private function populateDocumentRelationships(Document $collection, Document $d { $attributes = $collection->getAttribute('attributes', []); - $relationships = \array_filter( - $attributes, - fn ($attribute) => - $attribute['type'] === Database::VAR_RELATIONSHIP - ); + $relationships = \array_filter($attributes, function ($attribute) { + return $attribute['type'] === Database::VAR_RELATIONSHIP; + }); foreach ($relationships as $relationship) { $key = $relationship['key']; @@ -2264,7 +2267,7 @@ private function populateDocumentRelationships(Document $collection, Document $d $relationship->setAttribute('document', $document->getId()); $skipFetch = false; - foreach ($this->relationshipFetchMap as $fetchedRelationship) { + foreach ($this->relationshipFetchStack as $fetchedRelationship) { $existingKey = $fetchedRelationship['key']; $existingCollection = $fetchedRelationship['collection']; $existingRelatedCollection = $fetchedRelationship['options']['relatedCollection']; @@ -2321,12 +2324,12 @@ private function populateDocumentRelationships(Document $collection, Document $d } $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $related = $this->getDocument($relatedCollection->getId(), $value, $queries); $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); $document->setAttribute($key, $related); break; @@ -2338,12 +2341,12 @@ private function populateDocumentRelationships(Document $collection, Document $d } if (!\is_null($value)) { $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $related = $this->getDocument($relatedCollection->getId(), $value, $queries); $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); $document->setAttribute($key, $related); } @@ -2355,7 +2358,7 @@ private function populateDocumentRelationships(Document $collection, Document $d } $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $relatedDocuments = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, [$document->getId()]), @@ -2364,7 +2367,7 @@ private function populateDocumentRelationships(Document $collection, Document $d ]); $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); foreach ($relatedDocuments as $related) { $related->removeAttribute($twoWayKey); @@ -2383,12 +2386,12 @@ private function populateDocumentRelationships(Document $collection, Document $d break; } $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $related = $this->getDocument($relatedCollection->getId(), $value, $queries); $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); $document->setAttribute($key, $related); break; @@ -2404,7 +2407,7 @@ private function populateDocumentRelationships(Document $collection, Document $d } $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $relatedDocuments = $this->find($relatedCollection->getId(), [ Query::equal($twoWayKey, [$document->getId()]), @@ -2413,7 +2416,7 @@ private function populateDocumentRelationships(Document $collection, Document $d ]); $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); foreach ($relatedDocuments as $related) { @@ -2432,7 +2435,7 @@ private function populateDocumentRelationships(Document $collection, Document $d } $this->relationshipFetchDepth++; - $this->relationshipFetchMap[] = $relationship; + $this->relationshipFetchStack[] = $relationship; $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); @@ -2451,7 +2454,7 @@ private function populateDocumentRelationships(Document $collection, Document $d } $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchMap); + \array_pop($this->relationshipFetchStack); $document->setAttribute($key, $related); break; @@ -2881,7 +2884,6 @@ private function updateDocumentRelationships(Document $collection, Document $old if ($stackCount >= Database::RELATION_MAX_DEPTH - 1 && $this->relationshipWriteStack[$stackCount - 1] !== $relatedCollection->getId()) { $document->removeAttribute($key); - continue; } @@ -3377,6 +3379,7 @@ public function deleteDocument(string $collection, string $id): bool /** * @throws Exception + * @throws Throwable */ private function deleteDocumentRelationships(Document $collection, Document $document): Document { @@ -3396,6 +3399,9 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $onDelete = $relationship['options']['onDelete']; $side = $relationship['options']['side']; + $relationship->setAttribute('collection', $collection->getId()); + $relationship->setAttribute('document', $document->getId()); + switch ($onDelete) { case Database::RELATION_MUTATE_RESTRICT: $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); @@ -3404,7 +3410,51 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $this->deleteSetNull($collection, $relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); break; case Database::RELATION_MUTATE_CASCADE: - $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWay, $twoWayKey, $side); + foreach ($this->relationshipDeleteStack as $processedRelationship) { + $existingKey = $processedRelationship['key']; + $existingCollection = $processedRelationship['collection']; + $existingRelatedCollection = $processedRelationship['options']['relatedCollection']; + $existingTwoWayKey = $processedRelationship['options']['twoWayKey']; + $existingSide = $processedRelationship['options']['side']; + + // If this relationship has already been fetched for this document, skip it + $reflexive = $processedRelationship == $relationship; + + // If this relationship is the same as a previously fetched relationship, but on the other side, skip it + $symmetric = $existingKey === $twoWayKey + && $existingTwoWayKey === $key + && $existingRelatedCollection === $collection->getId() + && $existingCollection === $relatedCollection->getId() + && $existingSide !== $side; + + // If this relationship is not directly related but relates across multiple collections, skip it. + // + // These conditions ensure that a relationship is considered transitive if it has the same + // two-way key and related collection, but is on the opposite side of the relationship (the first and second conditions). + // + // They also ensure that a relationship is considered transitive if it has the same key and related + // collection as an existing relationship, but a different two-way key (the third condition), + // or the same two-way key as an existing relationship, but a different key (the fourth condition). + $transitive = (($existingKey === $twoWayKey + && $existingCollection === $relatedCollection->getId() + && $existingSide !== $side) + || ($existingTwoWayKey === $key + && $existingRelatedCollection === $collection->getId() + && $existingSide !== $side) + || ($existingKey === $key + && $existingTwoWayKey !== $twoWayKey + && $existingRelatedCollection === $relatedCollection->getId() + && $existingSide !== $side) + || ($existingKey !== $key + && $existingTwoWayKey === $twoWayKey + && $existingRelatedCollection === $relatedCollection->getId() + && $existingSide !== $side)); + + if ($reflexive || $symmetric || $transitive) { + break 2; + } + } + $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); break; } } @@ -3414,9 +3464,17 @@ private function deleteDocumentRelationships(Document $collection, Document $doc /** * @throws Exception + * @throws Throwable */ - private function deleteRestrict(Document $relatedCollection, Document $document, mixed $value, string $relationType, bool $twoWay, string $twoWayKey, string $side): void - { + private function deleteRestrict( + Document $relatedCollection, + Document $document, + mixed $value, + string $relationType, + bool $twoWay, + string $twoWayKey, + string $side + ): void { if ($value instanceof Document && $value->isEmpty()) { $value = null; } @@ -3443,6 +3501,7 @@ private function deleteRestrict(Document $relatedCollection, Document $document, return; } + $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), @@ -3465,8 +3524,16 @@ private function deleteRestrict(Document $relatedCollection, Document $document, } } - private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, mixed $value, string $relationType, bool $twoWay, string $twoWayKey, string $side): void - { + private function deleteSetNull( + Document $collection, + Document $relatedCollection, + Document $document, + mixed $value, + string $relationType, + bool $twoWay, + string $twoWayKey, + string $side + ): void { switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if (!$twoWay && $side === Database::RELATION_SIDE_PARENT) { @@ -3553,28 +3620,51 @@ private function deleteSetNull(Document $collection, Document $relatedCollection } } - private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, bool $twoWay, string $twoWayKey, string $side): void - { + /** + * @throws AuthorizationException + * @throws ConflictException + * @throws Throwable + */ + private function deleteCascade( + Document $collection, + Document $relatedCollection, + Document $document, + string $key, + mixed $value, + string $relationType, + string $twoWayKey, + string $side, + Document $relationship + ): void { switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if ($value !== null) { - $this->skipRelationships(fn () => + $this->relationshipDeleteStack[] = $relationship; + $this->deleteDocument( $relatedCollection->getId(), $value->getId() - )); + ); + + \array_pop($this->relationshipDeleteStack); } break; case Database::RELATION_ONE_TO_MANY: if ($side === Database::RELATION_SIDE_CHILD) { break; } + + $this->relationshipDeleteStack[] = $relationship; + foreach ($value as $relation) { - $this->skipRelationships(fn () => $this->deleteDocument( + $this->deleteDocument( $relatedCollection->getId(), $relation->getId() - )); + ); } + + \array_pop($this->relationshipDeleteStack); + break; case Database::RELATION_MANY_TO_ONE: if ($side === Database::RELATION_SIDE_PARENT) { @@ -3586,12 +3676,17 @@ private function deleteCascade(Document $collection, Document $relatedCollection Query::limit(PHP_INT_MAX) ]); + $this->relationshipDeleteStack[] = $relationship; + foreach ($value as $relation) { - $this->skipRelationships(fn () => $this->deleteDocument( + $this->deleteDocument( $relatedCollection->getId(), $relation->getId() - )); + ); } + + \array_pop($this->relationshipDeleteStack); + break; case Database::RELATION_MANY_TO_MANY: $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); @@ -3601,20 +3696,22 @@ private function deleteCascade(Document $collection, Document $relatedCollection Query::limit(PHP_INT_MAX) ]); + $this->relationshipDeleteStack[] = $relationship; + foreach ($junctions as $document) { - $this->skipRelationships(function () use ($document, $junction, $relatedCollection, $key, $side) { - if ($side === Database::RELATION_SIDE_PARENT) { - $this->deleteDocument( - $relatedCollection->getId(), - $document->getAttribute($key) - ); - } + if ($side === Database::RELATION_SIDE_PARENT) { $this->deleteDocument( - $junction, - $document->getId() + $relatedCollection->getId(), + $document->getAttribute($key) ); - }); + } + $this->deleteDocument( + $junction, + $document->getId() + ); } + + \array_pop($this->relationshipDeleteStack); break; } } @@ -3625,6 +3722,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection * @param string $collection * * @return bool + * @throws Exception */ public function deleteCachedCollection(string $collection): bool { diff --git a/tests/Database/Base.php b/tests/Database/Base.php index 83f8b3027..cca1848ba 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -10328,6 +10328,71 @@ public function testManyToManyRelationshipKeyWithSymbols(): void $this->assertEquals($doc1->getId(), $doc2->getAttribute('$symbols_coll.ection8')[0]->getId()); } + public function testCascadeMultiDelete(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection('cascadeMultiDelete1'); + static::getDatabase()->createCollection('cascadeMultiDelete2'); + static::getDatabase()->createCollection('cascadeMultiDelete3'); + + static::getDatabase()->createRelationship( + collection: 'cascadeMultiDelete1', + relatedCollection: 'cascadeMultiDelete2', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + onDelete: Database::RELATION_MUTATE_CASCADE + ); + + static::getDatabase()->createRelationship( + collection: 'cascadeMultiDelete2', + relatedCollection: 'cascadeMultiDelete3', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + onDelete: Database::RELATION_MUTATE_CASCADE + ); + + $root = static::getDatabase()->createDocument('cascadeMultiDelete1', new Document([ + '$id' => 'cascadeMultiDelete1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], + 'cascadeMultiDelete2' => [ + [ + '$id' => 'cascadeMultiDelete2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], + 'cascadeMultiDelete3' => [ + [ + '$id' => 'cascadeMultiDelete3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], + ], + ], + ], + ], + ])); + + $this->assertCount(1, $root->getAttribute('cascadeMultiDelete2')); + $this->assertCount(1, $root->getAttribute('cascadeMultiDelete2')[0]->getAttribute('cascadeMultiDelete3')); + + $this->assertEquals(true, static::getDatabase()->deleteDocument('cascadeMultiDelete1', $root->getId())); + + $multi2 = static::getDatabase()->getDocument('cascadeMultiDelete2', 'cascadeMultiDelete2'); + $this->assertEquals(true, $multi2->isEmpty()); + + $multi3 = static::getDatabase()->getDocument('cascadeMultiDelete3', 'cascadeMultiDelete3'); + $this->assertEquals(true, $multi3->isEmpty()); + } + public function testCollectionUpdate(): Document { $collection = static::getDatabase()->createCollection('collectionUpdate', permissions: [