diff --git a/src/Database/Database.php b/src/Database/Database.php index 73dc419e4..6c2c3f488 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7959,6 +7959,8 @@ public function decreaseDocumentAttribute( /** * Delete Document * + * Also fires EVENT_DOCUMENT_UPDATE for each document on the other side of a two-way relationship that the delete changed. + * * @param string $collection * @param string $id * @@ -7973,7 +7975,9 @@ public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - $deleted = $this->withTransaction(function () use ($collection, $id, &$document) { + $related = []; + + $deleted = $this->withTransaction(function () use ($collection, $id, &$document, &$related) { $document = $this->authorization->skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) )); @@ -8005,7 +8009,9 @@ public function deleteDocument(string $collection, string $id): bool } if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); + // A delete made while silenced, like a cascade's, has no one to report to + $report = $this->silentListeners !== null; + $related = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document, $report)); } $result = $this->adapter->deleteDocument($collection->getId(), $id); @@ -8019,6 +8025,10 @@ public function deleteDocument(string $collection, string $id): bool // Purge again after commit so readers cannot re-cache the pre-commit version $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); + + foreach ($related as $relation) { + $this->trigger(self::EVENT_DOCUMENT_UPDATE, $relation); + } } return $deleted; @@ -8027,15 +8037,19 @@ public function deleteDocument(string $collection, string $id): bool /** * @param Document $collection * @param Document $document - * @return Document + * @param bool $report + * @return array The two-way related documents left changed, when $report is set * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException * @throws RestrictedException * @throws StructureException */ - private function deleteDocumentRelationships(Document $collection, Document $document): Document + private function deleteDocumentRelationships(Document $collection, Document $document, bool $report = false): array { + $related = []; + $cascaded = false; + $attributes = $collection->getAttribute('attributes', []); $relationships = \array_filter($attributes, function ($attribute) { @@ -8055,14 +8069,33 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $relationship->setAttribute('collection', $collection->getId()); $relationship->setAttribute('document', $document->getId()); + // This side holds the key, so deleting it takes the reference with it and nothing writes the other side + $holdsKey = + ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_CHILD) || + ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_PARENT); + + // Whether the other side survives this delete without being written to + $unwritten = false; + switch ($onDelete) { case Database::RELATION_MUTATE_RESTRICT: $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); + $unwritten = true; break; case Database::RELATION_MUTATE_SET_NULL: - $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); + $updated = $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); + + if ($twoWay) { + foreach ($updated as $relation) { + $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; + } + } + + $unwritten = $holdsKey || $relationType === Database::RELATION_MANY_TO_MANY; break; case Database::RELATION_MUTATE_CASCADE: + $unwritten = $holdsKey || ($relationType === Database::RELATION_MANY_TO_MANY && $side === Database::RELATION_SIDE_CHILD); + foreach ($this->relationshipDeleteStack as $processedRelationship) { $existingKey = $processedRelationship['key']; $existingCollection = $processedRelationship['collection']; @@ -8110,9 +8143,56 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); break; } + + foreach (\is_array($value) ? $value : [$value] as $relation) { + if (!$relation instanceof Document || $relation->isEmpty()) { + continue; + } + + if ($onDelete === Database::RELATION_MUTATE_CASCADE && !$unwritten) { + $cascaded = true; + } elseif ($twoWay && $unwritten) { + $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; + } + } } - return $document; + if (!$report) { + return []; + } + + // A document related to itself is deleted, not changed + unset($related[$collection->getId() . ':' . $document->getId()]); + + if (!$cascaded || empty($related)) { + return $related; + } + + // A cascade can remove a related document anywhere down its chain, so keep only the ones still there + $idsByCollection = []; + foreach ($related as $relation) { + $idsByCollection[$relation->getCollection()][] = $relation->getId(); + } + + $existing = []; + foreach ($idsByCollection as $collectionId => $ids) { + foreach (\array_chunk($ids, \max(1, $this->maxQueryValues)) as $chunk) { + $found = $this->authorization->skip(fn () => $this->find($collectionId, [ + Query::equal('$id', $chunk), + Query::select(['$id']), + Query::limit(\count($chunk)), + ])); + + foreach ($found as $doc) { + $existing[$collectionId][$doc->getId()] = true; + } + } + } + + return \array_filter( + $related, + fn (Document $relation) => isset($existing[$relation->getCollection()][$relation->getId()]), + ); } /** @@ -8221,15 +8301,17 @@ private function findReferencingDocuments(Document $relatedCollection, Document * @param bool $twoWay * @param string $twoWayKey * @param string $side - * @return void + * @return array The documents written * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException * @throws RestrictedException * @throws StructureException */ - private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, string $relationType, bool $twoWay, string $twoWayKey, string $side): void + private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, string $relationType, bool $twoWay, string $twoWayKey, string $side): array { + $updated = []; + switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if (!$twoWay && $side === Database::RELATION_SIDE_PARENT) { @@ -8237,7 +8319,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection } // Shouldn't need read or update permission to delete - $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { + $result = $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { $related = $this->findOne($relatedCollection->getId(), [ Query::select(['$id']), Query::equal($twoWayKey, [$document->getId()]) @@ -8247,7 +8329,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection return; } - $this->skipRelationships(fn () => $this->updateDocument( + return $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), new Document([ @@ -8255,6 +8337,10 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]) )); }); + + if ($result !== null && !$result->isEmpty()) { + $updated[] = $result; + } break; case Database::RELATION_ONE_TO_MANY: @@ -8265,8 +8351,8 @@ private function deleteSetNull(Document $collection, Document $relatedCollection $relations = $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey); foreach ($relations as $relation) { - $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - $this->skipRelationships(fn () => $this->updateDocument( + $result = $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { + return $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), new Document([ @@ -8274,6 +8360,10 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]), )); }); + + if (!$result->isEmpty()) { + $updated[] = $result; + } } break; @@ -8285,8 +8375,8 @@ private function deleteSetNull(Document $collection, Document $relatedCollection $relations = $this->findReferencingDocuments($relatedCollection, $document, $twoWayKey); foreach ($relations as $relation) { - $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - $this->skipRelationships(fn () => $this->updateDocument( + $result = $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { + return $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), new Document([ @@ -8294,6 +8384,10 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]) )); }); + + if (!$result->isEmpty()) { + $updated[] = $result; + } } break; @@ -8314,6 +8408,8 @@ private function deleteSetNull(Document $collection, Document $relatedCollection } break; } + + return $updated; } /** @@ -8532,7 +8628,7 @@ public function deleteDocuments( } if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->deleteDocumentRelationships( + $this->silent(fn () => $this->deleteDocumentRelationships( $collection, $document )); diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index dbfba7bfc..2e4ecf89f 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4930,4 +4930,270 @@ public function testOrderAndCursorWithRelationshipQueries(): void $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } + + /** + * deleteDocument() fires an update for every document on the other side of a two-way relationship + * whose relationship the delete changed, including the ones it never writes to. + */ + public function testDeleteDocumentRelatedUpdateEvent(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionPermissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $documentPermissions = [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection('related_parent', permissions: $collectionPermissions, documentSecurity: true); + $database->createCollection('related_child', permissions: $collectionPermissions, documentSecurity: true); + + $database->createRelationship( + collection: 'related_parent', + relatedCollection: 'related_child', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'children', + twoWayKey: 'parent', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + foreach (['child1', 'child2'] as $childId) { + $database->createDocument('related_child', new Document([ + '$id' => $childId, + '$permissions' => $documentPermissions, + ])); + } + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent1', + '$permissions' => $documentPermissions, + 'children' => ['child1', 'child2'], + ])); + + // By id to look a peer up, in order so a peer fired twice fails + $reported = []; + $fired = []; + $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', function (string $event, Document $related) use (&$reported, &$fired) { + $reported[$related->getId()] = $related; + $fired[] = $related->getId(); + }); + + // The Database is shared across the suite, so the listener must not outlive a failure + try { + // Deleting the parent clears each child's reference, so each is reported once as the delete left it + $database->deleteDocument('related_parent', 'parent1'); + + $this->assertEqualsCanonicalizing(['child1', 'child2'], $fired); + $this->assertEquals('related_child', $reported['child1']->getCollection()); + $this->assertEquals( + $database->getDocument('related_child', 'child1')->getUpdatedAt(), + $reported['child1']->getUpdatedAt(), + ); + + // Deleting a child writes nothing to the parent, whose relationship still changed + $database->createDocument('related_parent', new Document([ + '$id' => 'parent2', + '$permissions' => $documentPermissions, + 'children' => ['child1'], + ])); + + $fired = []; + $database->deleteDocument('related_child', 'child1'); + + $this->assertEquals(['parent2'], $fired); + $this->assertEquals('related_parent', $reported['parent2']->getCollection()); + + // A cascaded document is gone, so it is not reported as changed + $database->updateRelationship( + collection: 'related_parent', + id: 'children', + onDelete: Database::RELATION_MUTATE_CASCADE, + ); + + $database->createDocument('related_child', new Document([ + '$id' => 'child3', + '$permissions' => $documentPermissions, + 'parent' => 'parent2', + ])); + + $fired = []; + $database->deleteDocument('related_parent', 'parent2'); + + $this->assertEquals([], $fired); + $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + + // Restrict allows deleting a child, and the parent still loses its reference to it + $database->updateRelationship( + collection: 'related_parent', + id: 'children', + onDelete: Database::RELATION_MUTATE_RESTRICT, + ); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent4', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_child', new Document([ + '$id' => 'child4', + '$permissions' => $documentPermissions, + 'parent' => 'parent4', + ])); + + $fired = []; + $database->deleteDocument('related_child', 'child4'); + + $this->assertEquals(['parent4'], $fired); + + // A one-way peer exposes no relationship, so it is not reported whether or not the delete wrote to it + $database->createCollection('related_oneway', permissions: $collectionPermissions, documentSecurity: true); + + $database->createRelationship( + collection: 'related_parent', + relatedCollection: 'related_oneway', + type: Database::RELATION_ONE_TO_MANY, + twoWay: false, + id: 'strays', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + $database->createRelationship( + collection: 'related_parent', + relatedCollection: 'related_oneway', + type: Database::RELATION_MANY_TO_ONE, + twoWay: false, + id: 'stray', + twoWayKey: 'strayOf', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent3', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_oneway', new Document([ + '$id' => 'stray1', + '$permissions' => $documentPermissions, + ])); + + $database->updateDocument('related_parent', 'parent3', new Document([ + 'strays' => ['stray1'], + 'stray' => 'stray1', + ])); + + $fired = []; + $database->deleteDocument('related_parent', 'parent3'); + + $this->assertEquals([], $fired); + $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + + // Reached through set-null but cascaded away through another relationship, so it is gone, not changed + $database->createCollection('related_pair', permissions: $collectionPermissions, documentSecurity: true); + + $database->createRelationship( + collection: 'related_parent', + relatedCollection: 'related_pair', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'owner', + twoWayKey: 'owned', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + $database->createRelationship( + collection: 'related_parent', + relatedCollection: 'related_pair', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'buddy', + twoWayKey: 'buddyOf', + onDelete: Database::RELATION_MUTATE_CASCADE, + ); + + $database->createDocument('related_pair', new Document([ + '$id' => 'pair1', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent5', + '$permissions' => $documentPermissions, + 'owner' => 'pair1', + 'buddy' => 'pair1', + ])); + + $fired = []; + $database->deleteDocument('related_parent', 'parent5'); + + $this->assertEquals([], $fired); + $this->assertTrue($database->getDocument('related_pair', 'pair1')->isEmpty()); + + // Removed further down a cascade chain, so it is gone, not changed, while its sibling survives + $database->updateRelationship( + collection: 'related_parent', + id: 'children', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + $database->createRelationship( + collection: 'related_pair', + relatedCollection: 'related_child', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'tail', + twoWayKey: 'tailOf', + onDelete: Database::RELATION_MUTATE_CASCADE, + ); + + $database->createDocument('related_child', new Document([ + '$id' => 'child5', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_child', new Document([ + '$id' => 'child6', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_pair', new Document([ + '$id' => 'pair2', + '$permissions' => $documentPermissions, + 'tail' => 'child5', + ])); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent6', + '$permissions' => $documentPermissions, + 'children' => ['child5', 'child6'], + 'buddy' => 'pair2', + ])); + + $fired = []; + $database->deleteDocument('related_parent', 'parent6'); + + $this->assertEquals(['child6'], $fired); + $this->assertTrue($database->getDocument('related_child', 'child5')->isEmpty()); + } finally { + $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', null); + } + + $database->deleteCollection('related_parent'); + $database->deleteCollection('related_child'); + $database->deleteCollection('related_oneway'); + $database->deleteCollection('related_pair'); + } }