From 67b0856263649f81399bd927d1fc22aa75bab0d6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 23 Sep 2026 12:44:03 +0530 Subject: [PATCH 01/16] feat: report related documents changed by a delete Deleting a document changes the documents on the other side of its two-way relationships, but nothing tells the caller which ones. The documents are not even a single set: a set-null peer is written and comes back from updateDocument(), while a document that merely held a reference to the deleted row is never written at all, because the foreign key lived on the row that went away. deleteDocument() now takes an optional $onRelated callback and reports both, once per document, after the transaction commits so nothing is announced for an attempt that rolled back. Documents the delete cascaded away are left out. Callers that pass nothing collect nothing, so this costs an existing caller a null check. --- src/Database/Database.php | 172 ++++++++++++++---- src/Database/Mirror.php | 5 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 105 +++++++++++ 3 files changed, 244 insertions(+), 38 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 73dc419e4..01a4a9987 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -485,6 +485,22 @@ class Database */ protected array $relationshipDeleteStack = []; + /** + * Documents on the other side of a relationship that a delete changed, keyed by + * collection and document id. Only collected while a delete is running. + * + * @var array|null + */ + protected ?array $relatedDocuments = null; + + /** + * Ids removed by the delete that is currently running, so cascaded documents are + * not reported as changed. + * + * @var array + */ + protected array $relatedDocumentsRemoved = []; + /** * Type mapping for collections to custom document classes * @var array> @@ -7959,8 +7975,15 @@ public function decreaseDocumentAttribute( /** * Delete Document * + * $onRelated is called once per document on the other side of a relationship whose + * relationship changed because of this delete, after the transaction commits. That + * covers documents this delete wrote, such as a set-null peer, and documents left + * holding a reference that is now gone, which are not written at all. Documents the + * delete cascaded away are not reported. + * * @param string $collection * @param string $id + * @param (callable(Document $related, Document $collection): void)|null $onRelated * * @return bool * @@ -7969,61 +7992,119 @@ public function decreaseDocumentAttribute( * @throws DatabaseException * @throws RestrictedException */ - public function deleteDocument(string $collection, string $id): bool + public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - $deleted = $this->withTransaction(function () use ($collection, $id, &$document) { - $document = $this->authorization->skip(fn () => $this->silent( - fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) - )); + // A cascade re-enters this method, so only the outermost call owns the buffer. + $collecting = $this->relatedDocuments === null; + $related = []; + $removed = []; - if ($document->isEmpty()) { - return false; - } + try { + $deleted = $this->withTransaction(function () use ($collection, $id, $collecting, &$document) { + // Reset inside the transaction so a retried attempt starts from an empty buffer. + if ($collecting) { + $this->relatedDocuments = []; + $this->relatedDocumentsRemoved = []; + } - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); + $document = $this->authorization->skip(fn () => $this->silent( + fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) + )); - if (!$this->authorization->isValid(new Input(self::PERMISSION_DELETE, [ - ...$collection->getDelete(), - ...($documentSecurity ? $document->getDelete() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); + if ($document->isEmpty()) { + return false; } - } - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); - if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } + if (!$this->authorization->isValid(new Input(self::PERMISSION_DELETE, [ + ...$collection->getDelete(), + ...($documentSecurity ? $document->getDelete() : []) + ]))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); - } + // Check if document was updated after the request timestamp + try { + $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } - $result = $this->adapter->deleteDocument($collection->getId(), $id); + if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } - $this->purgeCachedDocument($collection->getId(), $id); + if ($this->resolveRelationships) { + $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); + } - return $result; - }); + $result = $this->adapter->deleteDocument($collection->getId(), $id); + + if ($result) { + $this->relatedDocumentsRemoved[$this->relatedDocumentKey($collection->getId(), $id)] = true; + } + + $this->purgeCachedDocument($collection->getId(), $id); + + return $result; + }); + } finally { + if ($collecting) { + $related = $this->relatedDocuments ?? []; + $removed = $this->relatedDocumentsRemoved; + $this->relatedDocuments = null; + $this->relatedDocumentsRemoved = []; + } + } if ($deleted) { // 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); + + // After the commit, so nothing is reported for a transaction that rolled back. + if ($onRelated !== null) { + foreach ($related as $key => $entry) { + if (isset($removed[$key])) { + continue; + } + + $onRelated($entry['document'], $entry['collection']); + } + } } return $deleted; } + /** + * Record a document on the other side of a relationship that the running delete changed. + * + * A later call for the same document wins, so a document read from the deleted + * document's relationships is replaced by the copy the write returned. + */ + private function recordRelatedDocument(Document $collection, Document $document): void + { + if ($this->relatedDocuments === null || $document->isEmpty()) { + return; + } + + $this->relatedDocuments[$this->relatedDocumentKey($collection->getId(), $document->getId())] = [ + 'collection' => $collection, + 'document' => $document, + ]; + } + + private function relatedDocumentKey(string $collection, string $id): string + { + return $collection . ':' . $id; + } + /** * @param Document $collection * @param Document $document @@ -8055,6 +8136,17 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $relationship->setAttribute('collection', $collection->getId()); $relationship->setAttribute('document', $document->getId()); + // Documents on the other side hold a reference to this one, so their relationship + // changes whether or not the delete writes to them. A write below replaces these + // with the copy it returned, and a cascade drops them again. + if ($twoWay) { + foreach (\is_array($value) ? $value : [$value] as $relation) { + if ($relation instanceof Document) { + $this->recordRelatedDocument($relatedCollection, $relation); + } + } + } + switch ($onDelete) { case Database::RELATION_MUTATE_RESTRICT: $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); @@ -8165,13 +8257,15 @@ private function deleteRestrict( return; } - $this->skipRelationships(fn () => $this->updateDocument( + $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), new Document([ $twoWayKey => null ]) )); + + $this->recordRelatedDocument($relatedCollection, $updated); }); } @@ -8247,13 +8341,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection return; } - $this->skipRelationships(fn () => $this->updateDocument( + $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), new Document([ $twoWayKey => null ]) )); + + $this->recordRelatedDocument($relatedCollection, $updated); }); break; @@ -8266,13 +8362,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection foreach ($relations as $relation) { $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - $this->skipRelationships(fn () => $this->updateDocument( + $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), new Document([ $twoWayKey => null ]), )); + + $this->recordRelatedDocument($relatedCollection, $updated); }); } break; @@ -8286,13 +8384,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection foreach ($relations as $relation) { $this->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation) { - $this->skipRelationships(fn () => $this->updateDocument( + $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), new Document([ $twoWayKey => null ]) )); + + $this->recordRelatedDocument($relatedCollection, $updated); }); } break; diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92..cabc37972 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -868,9 +868,10 @@ public function upsertDocuments( return $modified; } - public function deleteDocument(string $collection, string $id): bool + public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool { - $result = $this->source->deleteDocument($collection, $id); + // Only the source reports related documents; the destination is a mirror of the same write. + $result = $this->source->deleteDocument($collection, $id, $onRelated); if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index dbfba7bfc..35370ca9b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4930,4 +4930,109 @@ public function testOrderAndCursorWithRelationshipQueries(): void $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } + + /** + * deleteDocument() reports 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 testDeleteDocumentRelatedCallback(): 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'], + ])); + + // Deleting the parent writes every child, so each one is reported with its + // reference already cleared. + $reported = []; + $database->deleteDocument('related_parent', 'parent1', function (Document $related, Document $collection) use (&$reported) { + $reported[$related->getId()] = $collection->getId(); + $this->assertNull($related->getAttribute('parent')); + }); + + $this->assertEquals([ + 'child1' => 'related_child', + 'child2' => 'related_child', + ], $reported); + + // Deleting a child writes nothing to the parent -- the foreign key lived on the + // deleted row -- but the parent's relationship changed, so it is still reported. + $database->createDocument('related_parent', new Document([ + '$id' => 'parent2', + '$permissions' => $documentPermissions, + 'children' => ['child1'], + ])); + + $reported = []; + $database->deleteDocument('related_child', 'child1', function (Document $related) use (&$reported) { + $reported[] = $related->getId(); + }); + + $this->assertEquals(['parent2'], $reported); + + // 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', + ])); + + $reported = []; + $database->deleteDocument('related_parent', 'parent2', function (Document $related) use (&$reported) { + $reported[] = $related->getId(); + }); + + $this->assertEquals([], $reported); + $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + + $database->deleteCollection('related_parent'); + $database->deleteCollection('related_child'); + } } From 464a146c4ea0e77b25182a9e94ff6cb004c6039c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 11:03:27 +0530 Subject: [PATCH 02/16] fix: scope the related-document buffer to the delete that asked for it Collecting ran for every delete, not just the ones passing a callback, so callers that wanted nothing still retained a document per set-null peer for the life of the transaction. Collect only when a callback is there. Ownership was decided by whether a buffer already existed, which made any re-entrant delete feed the buffer of the delete running around it and lose its own callback. A delete now owns a buffer when it asks for a report, a cascade keeps feeding the one it was handed, and anything else that re-enters collects nothing. Only a call that replaced the buffer restores it, so what a cascade records and removes still reaches the report it belongs to. Whether a one-way peer was reported depended on whether the delete happened to write it, because the walk was gated on twoWay and the writes were not. Report both. The docblock claimed delivery after the transaction commits. That only holds for a delete that owns its transaction: wrapped in a caller's own withTransaction() the callback runs before that commit, again on every retried attempt, and not at all if the caller rolls back. Say so, along with the callback seeing documents read with permissions skipped. The test asserted a null reference on a copy that never carried the key, so it passed with every write-path recording removed. Assert the key is present, which only the copy the write returned satisfies. --- src/Database/Database.php | 57 ++++++++++++++----- .../e2e/Adapter/Scopes/RelationshipTests.php | 14 +++-- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 01a4a9987..ace6e02a6 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7976,10 +7976,23 @@ public function decreaseDocumentAttribute( * Delete Document * * $onRelated is called once per document on the other side of a relationship whose - * relationship changed because of this delete, after the transaction commits. That - * covers documents this delete wrote, such as a set-null peer, and documents left - * holding a reference that is now gone, which are not written at all. Documents the - * delete cascaded away are not reported. + * relationship changed because of this delete, after this delete's own transaction + * ends. That covers documents this delete wrote, such as a set-null peer, and + * documents left holding a reference that is now gone, which are not written at all. + * Documents the delete cascaded away are not reported. + * + * Timing matches the bulk $onNext callbacks: it is not deferred past an enclosing + * transaction. A caller that wraps this in its own withTransaction() is called back + * before that transaction commits, is called again for every retried attempt, and is + * told nothing if the caller then rolls back. Throwing from $onRelated propagates, so + * a caller inside its own transaction can use it to abort. For a signal that only + * fires on durable state, act after your own withTransaction() returns. + * + * The reported document is the copy the delete itself worked with: read and written + * with permissions skipped, like the rest of the delete path, and handed over without + * a read check on the principal running the delete. A peer that principal cannot read + * still has its reference cleared, so it is still reported. Treat $onRelated as + * privileged, the same as an EVENT_DOCUMENT_DELETE listener. * * @param string $collection * @param string $id @@ -7996,11 +8009,21 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela { $collection = $this->silent(fn () => $this->getCollection($collection)); - // A cascade re-enters this method, so only the outermost call owns the buffer. - $collecting = $this->relatedDocuments === null; + // Collect only for a call that asked for a report, so every other delete keeps its + // memory. A cascade re-enters this method without a callback and keeps feeding the + // buffer it found; anything else that re-enters gets none, so it cannot report into + // the buffer of the delete running around it. + $collecting = $onRelated !== null; + $isolated = !$collecting && empty($this->relationshipDeleteStack); + $outerRelated = $this->relatedDocuments; + $outerRemoved = $this->relatedDocumentsRemoved; $related = []; $removed = []; + if ($isolated) { + $this->relatedDocuments = null; + } + try { $deleted = $this->withTransaction(function () use ($collection, $id, $collecting, &$document) { // Reset inside the transaction so a retried attempt starts from an empty buffer. @@ -8045,7 +8068,7 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela $result = $this->adapter->deleteDocument($collection->getId(), $id); - if ($result) { + if ($result && $this->relatedDocuments !== null) { $this->relatedDocumentsRemoved[$this->relatedDocumentKey($collection->getId(), $id)] = true; } @@ -8057,8 +8080,13 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela if ($collecting) { $related = $this->relatedDocuments ?? []; $removed = $this->relatedDocumentsRemoved; - $this->relatedDocuments = null; - $this->relatedDocumentsRemoved = []; + } + + // A cascade leaves the buffer it was handed alone, so what it recorded and + // what it removed both survive into the report of the delete that called it. + if ($collecting || $isolated) { + $this->relatedDocuments = $outerRelated; + $this->relatedDocumentsRemoved = $outerRemoved; } } @@ -8067,7 +8095,8 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); - // After the commit, so nothing is reported for a transaction that rolled back. + // After this delete's own transaction, so a delete that rolled back on its own + // reports nothing. An enclosing caller transaction commits later, see above. if ($onRelated !== null) { foreach ($related as $key => $entry) { if (isset($removed[$key])) { @@ -8139,11 +8168,9 @@ private function deleteDocumentRelationships(Document $collection, Document $doc // Documents on the other side hold a reference to this one, so their relationship // changes whether or not the delete writes to them. A write below replaces these // with the copy it returned, and a cascade drops them again. - if ($twoWay) { - foreach (\is_array($value) ? $value : [$value] as $relation) { - if ($relation instanceof Document) { - $this->recordRelatedDocument($relatedCollection, $relation); - } + foreach (\is_array($value) ? $value : [$value] as $relation) { + if ($relation instanceof Document) { + $this->recordRelatedDocument($relatedCollection, $relation); } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 35370ca9b..93af077ea 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4987,14 +4987,16 @@ public function testDeleteDocumentRelatedCallback(): void // reference already cleared. $reported = []; $database->deleteDocument('related_parent', 'parent1', function (Document $related, Document $collection) use (&$reported) { - $reported[$related->getId()] = $collection->getId(); - $this->assertNull($related->getAttribute('parent')); + $reported[$related->getId()] = $related; + $this->assertEquals('related_child', $collection->getId()); }); - $this->assertEquals([ - 'child1' => 'related_child', - 'child2' => 'related_child', - ], $reported); + $this->assertEqualsCanonicalizing(['child1', 'child2'], \array_keys($reported)); + + // A child read off the deleted parent carries no 'parent' key at all, so only the + // copy the set-null write returned can satisfy both of these. + $this->assertArrayHasKey('parent', $reported['child1']->getArrayCopy()); + $this->assertNull($reported['child1']->getAttribute('parent')); // Deleting a child writes nothing to the parent -- the foreign key lived on the // deleted row -- but the parent's relationship changed, so it is still reported. From b3e09c25598a2bda9118de20830f9f95dd9abca9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 11:27:21 +0530 Subject: [PATCH 03/16] fix: leave one-way peers out of the report Clearing a one-way peer's foreign key writes the row, but that key is internal and the peer exposes no relationship at all, so nothing a caller can observe about it changed. Reporting it makes callers invalidate or publish for state that is the same as it was. Both paths that record a peer now take the relationship's twoWay flag, so the walk and the writes agree instead of the answer depending on whether the delete happened to write the peer. --- src/Database/Database.php | 30 +++++++++------- .../e2e/Adapter/Scopes/RelationshipTests.php | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index ace6e02a6..baf0dc61a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7975,8 +7975,8 @@ public function decreaseDocumentAttribute( /** * Delete Document * - * $onRelated is called once per document on the other side of a relationship whose - * relationship changed because of this delete, after this delete's own transaction + * $onRelated is called once per document on the other side of a two-way relationship + * whose relationship changed because of this delete, after this delete's own transaction * ends. That covers documents this delete wrote, such as a set-null peer, and * documents left holding a reference that is now gone, which are not written at all. * Documents the delete cascaded away are not reported. @@ -8116,10 +8116,14 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela * * A later call for the same document wins, so a document read from the deleted * document's relationships is replaced by the copy the write returned. + * + * One-way peers are not recorded. The delete does clear their foreign key, but that + * key is internal and the peer exposes no relationship at all, so nothing a caller + * can observe about them changed. */ - private function recordRelatedDocument(Document $collection, Document $document): void + private function recordRelatedDocument(Document $collection, Document $document, bool $twoWay): void { - if ($this->relatedDocuments === null || $document->isEmpty()) { + if (!$twoWay || $this->relatedDocuments === null || $document->isEmpty()) { return; } @@ -8170,7 +8174,7 @@ private function deleteDocumentRelationships(Document $collection, Document $doc // with the copy it returned, and a cascade drops them again. foreach (\is_array($value) ? $value : [$value] as $relation) { if ($relation instanceof Document) { - $this->recordRelatedDocument($relatedCollection, $relation); + $this->recordRelatedDocument($relatedCollection, $relation, $twoWay); } } @@ -8274,7 +8278,7 @@ private function deleteRestrict( && $side === Database::RELATION_SIDE_CHILD && !$twoWay ) { - $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { + $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey, $twoWay) { $related = $this->findOne($relatedCollection->getId(), [ Query::select(['$id']), Query::equal($twoWayKey, [$document->getId()]) @@ -8292,7 +8296,7 @@ private function deleteRestrict( ]) )); - $this->recordRelatedDocument($relatedCollection, $updated); + $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); } @@ -8358,7 +8362,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) { + $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey, $twoWay) { $related = $this->findOne($relatedCollection->getId(), [ Query::select(['$id']), Query::equal($twoWayKey, [$document->getId()]) @@ -8376,7 +8380,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]) )); - $this->recordRelatedDocument($relatedCollection, $updated); + $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); break; @@ -8388,7 +8392,7 @@ 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->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation, $twoWay) { $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), @@ -8397,7 +8401,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]), )); - $this->recordRelatedDocument($relatedCollection, $updated); + $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); } break; @@ -8410,7 +8414,7 @@ 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->authorization->skip(function () use ($relatedCollection, $twoWayKey, $relation, $twoWay) { $updated = $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $relation->getId(), @@ -8419,7 +8423,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection ]) )); - $this->recordRelatedDocument($relatedCollection, $updated); + $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); } break; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 93af077ea..6b8948764 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5034,7 +5034,43 @@ public function testDeleteDocumentRelatedCallback(): void $this->assertEquals([], $reported); $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + // A one-way peer exposes no relationship of its own, so clearing its internal + // foreign key changes nothing a caller can observe and it is not reported. + $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->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'], + ])); + + $reported = []; + $database->deleteDocument('related_parent', 'parent3', function (Document $related) use (&$reported) { + $reported[] = $related->getId(); + }); + + $this->assertEquals([], $reported); + $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + $database->deleteCollection('related_parent'); $database->deleteCollection('related_child'); + $database->deleteCollection('related_oneway'); } } From ded2862e3d9d6b69179161bc8d33e86c54465386 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 11:39:54 +0530 Subject: [PATCH 04/16] fix: hold the related-document report until the outermost transaction commits A delete wrapped in a caller's own withTransaction() left a nested transaction, not a real one, so the report went out while the delete was still undone-able. A caller that rolled back had already been told its peers changed, and a retried attempt reported them again. withTransaction() now tracks its own depth and holds reports queued by a delete running under it, releasing them once the outermost transaction commits and dropping them if it does not. The queue clears per attempt, so a retry replaces what the abandoned attempt queued rather than adding to it. Only the related-document report is held. The bulk $onNext callbacks keep firing where they always have, so a caller that throws from one to abort its enclosing transaction still can. --- src/Database/Database.php | 66 ++++++++++++-- .../e2e/Adapter/Scopes/RelationshipTests.php | 85 +++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index baf0dc61a..1db2f876c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -501,6 +501,19 @@ class Database */ protected array $relatedDocumentsRemoved = []; + /** + * How deep withTransaction() is nested, so a delete can tell whether the transaction + * it just left was really committed or is still someone else's to commit. + */ + protected int $transactionDepth = 0; + + /** + * Related-document reports waiting for the outermost transaction to commit. + * + * @var array + */ + protected array $pendingRelated = []; + /** * Type mapping for collections to custom document classes * @var array> @@ -1720,7 +1733,39 @@ public function getAdapter(): Adapter */ public function withTransaction(callable $callback): mixed { - return $this->adapter->withTransaction($callback); + $outermost = $this->transactionDepth === 0; + $this->transactionDepth++; + + try { + $result = $this->adapter->withTransaction(function () use ($callback, $outermost) { + // Cleared per attempt: the adapter retries this closure, and a retry must + // not report what an abandoned attempt queued. + if ($outermost) { + $this->pendingRelated = []; + } + + return $callback(); + }); + } catch (\Throwable $th) { + if ($outermost) { + $this->pendingRelated = []; + } + + throw $th; + } finally { + $this->transactionDepth--; + } + + if ($outermost && !empty($this->pendingRelated)) { + $pending = $this->pendingRelated; + $this->pendingRelated = []; + + foreach ($pending as [$onRelated, $related, $collection]) { + $onRelated($related, $collection); + } + } + + return $result; } /** @@ -7981,12 +8026,10 @@ public function decreaseDocumentAttribute( * documents left holding a reference that is now gone, which are not written at all. * Documents the delete cascaded away are not reported. * - * Timing matches the bulk $onNext callbacks: it is not deferred past an enclosing - * transaction. A caller that wraps this in its own withTransaction() is called back - * before that transaction commits, is called again for every retried attempt, and is - * told nothing if the caller then rolls back. Throwing from $onRelated propagates, so - * a caller inside its own transaction can use it to abort. For a signal that only - * fires on durable state, act after your own withTransaction() returns. + * Delivery waits for the outermost transaction, so a caller that wraps this in its + * own withTransaction() is called back once, after that transaction commits, and not + * at all if it rolls back or if a retried attempt is abandoned. Throwing from + * $onRelated therefore cannot abort the delete: by then it is durable. * * The reported document is the copy the delete itself worked with: read and written * with permissions skipped, like the rest of the delete path, and handed over without @@ -8095,14 +8138,19 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); - // After this delete's own transaction, so a delete that rolled back on its own - // reports nothing. An enclosing caller transaction commits later, see above. if ($onRelated !== null) { foreach ($related as $key => $entry) { if (isset($removed[$key])) { continue; } + // Still inside a caller's transaction, so this delete is not durable + // yet. Hold the report until whoever owns that transaction commits. + if ($this->transactionDepth > 0) { + $this->pendingRelated[] = [$onRelated, $entry['document'], $entry['collection']]; + continue; + } + $onRelated($entry['document'], $entry['collection']); } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 6b8948764..ceea8bc97 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5073,4 +5073,89 @@ public function testDeleteDocumentRelatedCallback(): void $database->deleteCollection('related_child'); $database->deleteCollection('related_oneway'); } + + /** + * A delete inside someone else's transaction is not durable until that transaction + * commits, so the report waits for it and never arrives if it rolls back. + */ + public function testDeleteDocumentRelatedCallbackWaitsForOuterCommit(): 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('outer_parent', permissions: $collectionPermissions, documentSecurity: true); + $database->createCollection('outer_child', permissions: $collectionPermissions, documentSecurity: true); + + $database->createRelationship( + collection: 'outer_parent', + relatedCollection: 'outer_child', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'children', + twoWayKey: 'parent', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); + + foreach (['kept', 'dropped'] as $suffix) { + $database->createDocument('outer_child', new Document([ + '$id' => 'child_' . $suffix, + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('outer_parent', new Document([ + '$id' => 'parent_' . $suffix, + '$permissions' => $documentPermissions, + 'children' => ['child_' . $suffix], + ])); + } + + // Rolled back: the delete never happened, so nothing is reported. + $reported = []; + $collect = function (Document $related) use (&$reported) { + $reported[] = $related->getId(); + }; + + try { + $database->withTransaction(function () use ($database, $collect) { + $database->deleteDocument('outer_parent', 'parent_dropped', $collect); + throw new Exception('abandon the transaction'); + }); + $this->fail('The transaction should have propagated its exception'); + } catch (Exception $e) { + $this->assertEquals('abandon the transaction', $e->getMessage()); + } + + $this->assertEquals([], $reported); + $this->assertFalse($database->getDocument('outer_parent', 'parent_dropped')->isEmpty()); + + // Committed: reported once, and only after the outer transaction closed. + $seenInside = null; + $database->withTransaction(function () use ($database, $collect, &$seenInside, &$reported) { + $database->deleteDocument('outer_parent', 'parent_kept', $collect); + $seenInside = $reported; + }); + + $this->assertEquals([], $seenInside); + $this->assertEquals(['child_kept'], $reported); + + $database->deleteCollection('outer_parent'); + $database->deleteCollection('outer_child'); + } } From a4e90aca94ddfe921eae1a1ee826818391398d31 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 12:17:56 +0530 Subject: [PATCH 05/16] fix: hold reports on the database that owns the transaction A mirror shares its source's adapter but is its own Database object, so wrapping a delete in the mirror's transaction moved the mirror's depth while the delete ran, and reported, on the source. A rolled back mirror transaction told the caller its peers had changed, once per retried attempt. The mirror now runs transactions through its source, which is where the writes and the reports already happen. Reports queued by an attempt are also discarded at every level of nesting rather than only the outermost, so a retried savepoint no longer leaves behind what the attempt before it queued. --- src/Database/Database.php | 25 +++++++++---------- src/Database/Mirror.php | 8 ++++++ .../e2e/Adapter/Scopes/RelationshipTests.php | 7 ++++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 1db2f876c..fe39c83af 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1737,21 +1737,20 @@ public function withTransaction(callable $callback): mixed $this->transactionDepth++; try { - $result = $this->adapter->withTransaction(function () use ($callback, $outermost) { - // Cleared per attempt: the adapter retries this closure, and a retry must - // not report what an abandoned attempt queued. - if ($outermost) { - $this->pendingRelated = []; - } + $result = $this->adapter->withTransaction(function () use ($callback) { + // Every attempt starts from what was already queued before it, at every + // level of nesting, so an attempt the adapter abandons and retries takes + // the reports it queued away with it. + $queued = $this->pendingRelated; - return $callback(); - }); - } catch (\Throwable $th) { - if ($outermost) { - $this->pendingRelated = []; - } + try { + return $callback(); + } catch (\Throwable $th) { + $this->pendingRelated = $queued; - throw $th; + throw $th; + } + }); } finally { $this->transactionDepth--; } diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index cabc37972..9b11dec1d 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -868,6 +868,14 @@ public function upsertDocuments( return $modified; } + public function withTransaction(callable $callback): mixed + { + // The source runs the writes and owns the transaction, so its bookkeeping is the + // one that has to see this nesting. A mirror shares the source's adapter, so the + // transaction itself is the same either way. + return $this->source->withTransaction($callback); + } + public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool { // Only the source reports related documents; the destination is a mirror of the same write. diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index ceea8bc97..8ede5987b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5132,16 +5132,19 @@ public function testDeleteDocumentRelatedCallbackWaitsForOuterCommit(): void $reported[] = $related->getId(); }; + $abandoned = null; + try { $database->withTransaction(function () use ($database, $collect) { $database->deleteDocument('outer_parent', 'parent_dropped', $collect); + throw new Exception('abandon the transaction'); }); - $this->fail('The transaction should have propagated its exception'); } catch (Exception $e) { - $this->assertEquals('abandon the transaction', $e->getMessage()); + $abandoned = $e->getMessage(); } + $this->assertEquals('abandon the transaction', $abandoned); $this->assertEquals([], $reported); $this->assertFalse($database->getDocument('outer_parent', 'parent_dropped')->isEmpty()); From 41e98c501f809a8fe593b454c2832e8bd4639c49 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 12:30:28 +0530 Subject: [PATCH 06/16] fix: discard queued reports when a commit fails and the transaction retries The queue was only rewound when the transaction callback itself threw. A commit that fails after the callback has already returned is caught and retried inside the adapter, so the reports the abandoned attempt queued stayed behind and the attempt that eventually committed added its own on top. Each attempt now starts from the queue as it was before the transaction opened, which covers a failure wherever it happens. --- src/Database/Database.php | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index fe39c83af..4f504d3be 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1736,21 +1736,22 @@ public function withTransaction(callable $callback): mixed $outermost = $this->transactionDepth === 0; $this->transactionDepth++; - try { - $result = $this->adapter->withTransaction(function () use ($callback) { - // Every attempt starts from what was already queued before it, at every - // level of nesting, so an attempt the adapter abandons and retries takes - // the reports it queued away with it. - $queued = $this->pendingRelated; + $queued = $this->pendingRelated; - try { - return $callback(); - } catch (\Throwable $th) { - $this->pendingRelated = $queued; + try { + $result = $this->adapter->withTransaction(function () use ($callback, $queued) { + // Every attempt starts from what was queued before this transaction, so an + // attempt the adapter abandons takes its own reports with it. Resetting + // here rather than on the way out also covers a commit that fails after + // the callback already returned. + $this->pendingRelated = $queued; - throw $th; - } + return $callback(); }); + } catch (\Throwable $th) { + $this->pendingRelated = $queued; + + throw $th; } finally { $this->transactionDepth--; } From 5e5753c31058718c306d681858bf4201c4570ae5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 12:31:26 +0530 Subject: [PATCH 07/16] docs: state who the related-document report is for, and at what trust level The docblock said the report is privileged without saying why that is the right default, which reads like an oversight rather than a decision. Whoever can read a peer is who needs to hear that it changed, and that is rarely whoever deleted the other side, so filtering the report by the deleter's read permission would drop events that have an audience. Name the bulk callbacks that already carry the same trust so the precedent sits with the code. --- src/Database/Database.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 4f504d3be..835a10f68 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8034,8 +8034,12 @@ public function decreaseDocumentAttribute( * The reported document is the copy the delete itself worked with: read and written * with permissions skipped, like the rest of the delete path, and handed over without * a read check on the principal running the delete. A peer that principal cannot read - * still has its reference cleared, so it is still reported. Treat $onRelated as - * privileged, the same as an EVENT_DOCUMENT_DELETE listener. + * still has its reference cleared, so it is still reported: whoever can read the peer + * is who needs to hear that it changed, and that is rarely whoever deleted the other + * side. Treat $onRelated as privileged, the same trust level the bulk callbacks + * already carry - deleteDocuments() selects its batch by DELETE and hands $onNext the + * whole document, and upsertDocuments() hands it a pre-image read with permissions + * skipped behind an UPDATE check. * * @param string $collection * @param string $id From 8fbd1ed2b7c3999e179aec1f703cc59eedbecb77 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 12:39:45 +0530 Subject: [PATCH 08/16] fix: one failing report must not cost the others theirs The reports run after the commit, so by then the delete is durable and there is nothing left to abort. A callback that threw took the rest of the queue with it and surfaced from withTransaction() looking exactly like a rollback. Every queued report now runs, and the first failure is raised once they have. The caller's transaction result is still lost in that case, which is inherent - a method cannot both return and throw - but nothing goes unreported. --- src/Database/Database.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 835a10f68..33498c27d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1759,9 +1759,20 @@ public function withTransaction(callable $callback): mixed if ($outermost && !empty($this->pendingRelated)) { $pending = $this->pendingRelated; $this->pendingRelated = []; + $failure = null; + // The transaction is already committed, so one report that throws must not + // cost the others theirs. The first failure surfaces once they have all run. foreach ($pending as [$onRelated, $related, $collection]) { - $onRelated($related, $collection); + try { + $onRelated($related, $collection); + } catch (\Throwable $th) { + $failure ??= $th; + } + } + + if ($failure !== null) { + throw $failure; } } From 624cd66f03853a3096afb81baaa0bafd57e5f286 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 24 Sep 2026 12:56:40 +0530 Subject: [PATCH 09/16] revert: report when the delete's own transaction ends, not the outermost Holding reports until the outermost transaction committed was a change to this library's transaction semantics smuggled into a feature that adds one callback, and it did not pay for itself. It needed a per-Database depth counter guarding a per-adapter resource, which a mirror got wrong because it shares its source's adapter; it needed the queue rewound at every level and again around the adapter call, because a commit fails outside the callback it wraps; and it needed the drain guarded, because a report that threw after the commit looked exactly like a rollback to the caller. Worse, deferral broke the guarantee the report is supposed to carry. Whether a peer still exists is decided per delete, at the moment it is queued. Once the report waits for a transaction that runs several deletes, a peer removed by a later one is still announced as changed, the same peer is announced twice, and the document handed over is a snapshot any later write invalidates. Reporting when the delete's own transaction ends has none of that, and it is the contract the four bulk $onNext callbacks already have. A caller that needs a signal only on durable state acts after its own withTransaction() returns, which the docblock says. --- src/Database/Database.php | 77 ++-------------- src/Database/Mirror.php | 8 -- .../e2e/Adapter/Scopes/RelationshipTests.php | 88 ------------------- 3 files changed, 9 insertions(+), 164 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 33498c27d..afe40ae73 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -501,19 +501,6 @@ class Database */ protected array $relatedDocumentsRemoved = []; - /** - * How deep withTransaction() is nested, so a delete can tell whether the transaction - * it just left was really committed or is still someone else's to commit. - */ - protected int $transactionDepth = 0; - - /** - * Related-document reports waiting for the outermost transaction to commit. - * - * @var array - */ - protected array $pendingRelated = []; - /** * Type mapping for collections to custom document classes * @var array> @@ -1733,50 +1720,7 @@ public function getAdapter(): Adapter */ public function withTransaction(callable $callback): mixed { - $outermost = $this->transactionDepth === 0; - $this->transactionDepth++; - - $queued = $this->pendingRelated; - - try { - $result = $this->adapter->withTransaction(function () use ($callback, $queued) { - // Every attempt starts from what was queued before this transaction, so an - // attempt the adapter abandons takes its own reports with it. Resetting - // here rather than on the way out also covers a commit that fails after - // the callback already returned. - $this->pendingRelated = $queued; - - return $callback(); - }); - } catch (\Throwable $th) { - $this->pendingRelated = $queued; - - throw $th; - } finally { - $this->transactionDepth--; - } - - if ($outermost && !empty($this->pendingRelated)) { - $pending = $this->pendingRelated; - $this->pendingRelated = []; - $failure = null; - - // The transaction is already committed, so one report that throws must not - // cost the others theirs. The first failure surfaces once they have all run. - foreach ($pending as [$onRelated, $related, $collection]) { - try { - $onRelated($related, $collection); - } catch (\Throwable $th) { - $failure ??= $th; - } - } - - if ($failure !== null) { - throw $failure; - } - } - - return $result; + return $this->adapter->withTransaction($callback); } /** @@ -8037,10 +7981,12 @@ public function decreaseDocumentAttribute( * documents left holding a reference that is now gone, which are not written at all. * Documents the delete cascaded away are not reported. * - * Delivery waits for the outermost transaction, so a caller that wraps this in its - * own withTransaction() is called back once, after that transaction commits, and not - * at all if it rolls back or if a retried attempt is abandoned. Throwing from - * $onRelated therefore cannot abort the delete: by then it is durable. + * Timing matches the bulk $onNext callbacks: it is not deferred past an enclosing + * transaction. A caller that wraps this in its own withTransaction() is called back + * before that transaction commits, is called again for every retried attempt, and is + * told nothing if the caller then rolls back. Throwing from $onRelated propagates, so + * a caller inside its own transaction can use it to abort. For a signal that only + * fires on durable state, act after your own withTransaction() returns. * * The reported document is the copy the delete itself worked with: read and written * with permissions skipped, like the rest of the delete path, and handed over without @@ -8153,19 +8099,14 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); + // After this delete's own transaction, so a delete that rolled back on its own + // reports nothing. An enclosing caller transaction commits later, see above. if ($onRelated !== null) { foreach ($related as $key => $entry) { if (isset($removed[$key])) { continue; } - // Still inside a caller's transaction, so this delete is not durable - // yet. Hold the report until whoever owns that transaction commits. - if ($this->transactionDepth > 0) { - $this->pendingRelated[] = [$onRelated, $entry['document'], $entry['collection']]; - continue; - } - $onRelated($entry['document'], $entry['collection']); } } diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 9b11dec1d..cabc37972 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -868,14 +868,6 @@ public function upsertDocuments( return $modified; } - public function withTransaction(callable $callback): mixed - { - // The source runs the writes and owns the transaction, so its bookkeeping is the - // one that has to see this nesting. A mirror shares the source's adapter, so the - // transaction itself is the same either way. - return $this->source->withTransaction($callback); - } - public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool { // Only the source reports related documents; the destination is a mirror of the same write. diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 8ede5987b..6b8948764 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5073,92 +5073,4 @@ public function testDeleteDocumentRelatedCallback(): void $database->deleteCollection('related_child'); $database->deleteCollection('related_oneway'); } - - /** - * A delete inside someone else's transaction is not durable until that transaction - * commits, so the report waits for it and never arrives if it rolls back. - */ - public function testDeleteDocumentRelatedCallbackWaitsForOuterCommit(): 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('outer_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('outer_child', permissions: $collectionPermissions, documentSecurity: true); - - $database->createRelationship( - collection: 'outer_parent', - relatedCollection: 'outer_child', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - id: 'children', - twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); - - foreach (['kept', 'dropped'] as $suffix) { - $database->createDocument('outer_child', new Document([ - '$id' => 'child_' . $suffix, - '$permissions' => $documentPermissions, - ])); - - $database->createDocument('outer_parent', new Document([ - '$id' => 'parent_' . $suffix, - '$permissions' => $documentPermissions, - 'children' => ['child_' . $suffix], - ])); - } - - // Rolled back: the delete never happened, so nothing is reported. - $reported = []; - $collect = function (Document $related) use (&$reported) { - $reported[] = $related->getId(); - }; - - $abandoned = null; - - try { - $database->withTransaction(function () use ($database, $collect) { - $database->deleteDocument('outer_parent', 'parent_dropped', $collect); - - throw new Exception('abandon the transaction'); - }); - } catch (Exception $e) { - $abandoned = $e->getMessage(); - } - - $this->assertEquals('abandon the transaction', $abandoned); - $this->assertEquals([], $reported); - $this->assertFalse($database->getDocument('outer_parent', 'parent_dropped')->isEmpty()); - - // Committed: reported once, and only after the outer transaction closed. - $seenInside = null; - $database->withTransaction(function () use ($database, $collect, &$seenInside, &$reported) { - $database->deleteDocument('outer_parent', 'parent_kept', $collect); - $seenInside = $reported; - }); - - $this->assertEquals([], $seenInside); - $this->assertEquals(['child_kept'], $reported); - - $database->deleteCollection('outer_parent'); - $database->deleteCollection('outer_child'); - } } From 36edf12a9e2427343298125d5c92ce735f07aa73 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 11:19:01 +0530 Subject: [PATCH 10/16] docs: say which shape a reported peer arrives in The callback hands over two different documents depending on how the delete reached the peer, and the contract never said so. A peer the delete wrote is the copy that write returned, with the cleared key on it. A peer it did not write is the copy read off the deleted document, where relationship population has already removed the back-reference, so the same key is absent rather than null. A consumer reading that key to decide what changed would be right on one path and wrong on the other. --- src/Database/Database.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index afe40ae73..a5b39dc91 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7998,6 +7998,12 @@ public function decreaseDocumentAttribute( * whole document, and upsertDocuments() hands it a pre-image read with permissions * skipped behind an UPDATE check. * + * How the delete reached a peer decides the shape it arrives in. One the delete wrote + * is the copy that write returned, carrying the key it cleared. One it did not write + * is the copy read off the deleted document, and relationship population has already + * stripped the back-reference from it, so that key is absent rather than null. Read a + * peer back if you need more of it than its identity. + * * @param string $collection * @param string $id * @param (callable(Document $related, Document $collection): void)|null $onRelated From 1f4ad1b36609a27e2a8e3407875e7ee274c37e12 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 12:32:41 +0530 Subject: [PATCH 11/16] feat: fire an event for related documents a delete changed A delete that changes the other side of a two-way relationship now fires EVENT_DOCUMENT_RELATED_UPDATE once per changed peer, instead of reporting them through a parameter on deleteDocument(). Anything that needs to know subscribes with on(), the same way it already hears about the delete itself, and deleteDocument() and Mirror keep their original signatures. The peers are still gathered during teardown and fired after the delete's own transaction, next to EVENT_DOCUMENT_DELETE: teardown runs with every listener silenced, so an event fired where a peer is found would never arrive. Gathering is skipped unless a listener that is not silenced would receive the event, which keeps a delete that nobody listens to from holding a copy of every peer. A delete started from another listener collects into its own buffer and puts back the one it found, rather than being shut out. Also drops a record call on a one-way restrict branch that could never record anything, and says on deleteDocuments() that it does not fire the event. --- src/Database/Database.php | 104 +++++++------ src/Database/Mirror.php | 5 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 145 +++++++++--------- 3 files changed, 131 insertions(+), 123 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index a5b39dc91..34986c34e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -206,6 +206,7 @@ class Database public const EVENT_DOCUMENTS_UPDATE = 'documents_update'; public const EVENT_DOCUMENTS_UPSERT = 'documents_upsert'; public const EVENT_DOCUMENT_DELETE = 'document_delete'; + public const EVENT_DOCUMENT_RELATED_UPDATE = 'document_related_update'; public const EVENT_DOCUMENTS_DELETE = 'documents_delete'; public const EVENT_DOCUMENT_COUNT = 'document_count'; public const EVENT_DOCUMENT_SUM = 'document_sum'; @@ -487,15 +488,16 @@ class Database /** * Documents on the other side of a relationship that a delete changed, keyed by - * collection and document id. Only collected while a delete is running. + * collection and document id. Only collected while a delete that something is + * listening to is running. * - * @var array|null + * @var array|null */ protected ?array $relatedDocuments = null; /** - * Ids removed by the delete that is currently running, so cascaded documents are - * not reported as changed. + * Collection-and-id keys removed by the delete that is currently running, so + * cascaded documents are not reported as changed. * * @var array */ @@ -7975,38 +7977,40 @@ public function decreaseDocumentAttribute( /** * Delete Document * - * $onRelated is called once per document on the other side of a two-way relationship - * whose relationship changed because of this delete, after this delete's own transaction - * ends. That covers documents this delete wrote, such as a set-null peer, and - * documents left holding a reference that is now gone, which are not written at all. - * Documents the delete cascaded away are not reported. - * - * Timing matches the bulk $onNext callbacks: it is not deferred past an enclosing - * transaction. A caller that wraps this in its own withTransaction() is called back - * before that transaction commits, is called again for every retried attempt, and is - * told nothing if the caller then rolls back. Throwing from $onRelated propagates, so - * a caller inside its own transaction can use it to abort. For a signal that only - * fires on durable state, act after your own withTransaction() returns. - * - * The reported document is the copy the delete itself worked with: read and written - * with permissions skipped, like the rest of the delete path, and handed over without - * a read check on the principal running the delete. A peer that principal cannot read - * still has its reference cleared, so it is still reported: whoever can read the peer - * is who needs to hear that it changed, and that is rarely whoever deleted the other - * side. Treat $onRelated as privileged, the same trust level the bulk callbacks - * already carry - deleteDocuments() selects its batch by DELETE and hands $onNext the - * whole document, and upsertDocuments() hands it a pre-image read with permissions - * skipped behind an UPDATE check. + * Fires EVENT_DOCUMENT_RELATED_UPDATE once per document on the other side of a two-way + * relationship whose relationship changed because of this delete, after this delete's + * own transaction ends. That covers documents this delete wrote, such as a set-null + * peer, and documents left holding a reference that is now gone, which are not written + * at all. Documents the delete cascaded away are not reported. The peers are only + * gathered when a listener that is not silenced is registered for that event. + * deleteDocuments() clears the same relationships but does not fire it. + * + * Like EVENT_DOCUMENT_DELETE, which fires just before it, the event is not deferred + * past an enclosing transaction. Wrapped in a caller's own withTransaction(), it fires + * before that transaction commits, again for every retried attempt, and whether or not + * the caller then rolls back. A listener that needs durable state should hold what it + * receives until that withTransaction() returns. A listener that throws propagates out + * of this call after the delete has committed, so the throw does not undo it; peers + * after it are not reported, and neither is any if an EVENT_DOCUMENT_DELETE listener + * throws first. + * + * The document a listener receives is the copy the delete itself worked with: read and + * written with permissions skipped, like the rest of the delete path, and handed over + * without a read check on the principal running the delete. A peer that principal + * cannot read still has its reference cleared, so it is still reported: whoever can + * read the peer is who needs to hear that it changed, and that is rarely whoever + * deleted the other side. Treat the listener as privileged. EVENT_DOCUMENT_DELETE is + * already handed these same peers inside the deleted document, read the same way. * * How the delete reached a peer decides the shape it arrives in. One the delete wrote * is the copy that write returned, carrying the key it cleared. One it did not write * is the copy read off the deleted document, and relationship population has already - * stripped the back-reference from it, so that key is absent rather than null. Read a - * peer back if you need more of it than its identity. + * stripped the back-reference from it, so that key is absent rather than null. Either + * shape names its collection through getCollection(). Read a peer back if you need + * more of it than its identity. * * @param string $collection * @param string $id - * @param (callable(Document $related, Document $collection): void)|null $onRelated * * @return bool * @@ -8015,15 +8019,20 @@ public function decreaseDocumentAttribute( * @throws DatabaseException * @throws RestrictedException */ - public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool + public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - // Collect only for a call that asked for a report, so every other delete keeps its - // memory. A cascade re-enters this method without a callback and keeps feeding the - // buffer it found; anything else that re-enters gets none, so it cannot report into - // the buffer of the delete running around it. - $collecting = $onRelated !== null; + // Gather peers only when a listener would actually receive them, so every other delete + // keeps its memory. Relationship teardown runs with every listener silenced, so a + // cascade re-entering this method finds nobody to report to and keeps feeding the + // buffer it was handed. A separate delete started from a listener collects into its + // own buffer, and the one around it is restored below. + $collecting = $this->silentListeners !== null + && !empty(\array_diff_key( + ($this->listeners[self::EVENT_DOCUMENT_RELATED_UPDATE] ?? []) + $this->listeners[self::EVENT_ALL], + $this->silentListeners, + )); $isolated = !$collecting && empty($this->relationshipDeleteStack); $outerRelated = $this->relatedDocuments; $outerRemoved = $this->relatedDocumentsRemoved; @@ -8107,14 +8116,12 @@ public function deleteDocument(string $collection, string $id, ?callable $onRela // After this delete's own transaction, so a delete that rolled back on its own // reports nothing. An enclosing caller transaction commits later, see above. - if ($onRelated !== null) { - foreach ($related as $key => $entry) { - if (isset($removed[$key])) { - continue; - } - - $onRelated($entry['document'], $entry['collection']); + foreach ($related as $key => $peer) { + if (isset($removed[$key])) { + continue; } + + $this->trigger(self::EVENT_DOCUMENT_RELATED_UPDATE, $peer); } } @@ -8137,10 +8144,7 @@ private function recordRelatedDocument(Document $collection, Document $document, return; } - $this->relatedDocuments[$this->relatedDocumentKey($collection->getId(), $document->getId())] = [ - 'collection' => $collection, - 'document' => $document, - ]; + $this->relatedDocuments[$this->relatedDocumentKey($collection->getId(), $document->getId())] = $document; } private function relatedDocumentKey(string $collection, string $id): string @@ -8288,7 +8292,7 @@ private function deleteRestrict( && $side === Database::RELATION_SIDE_CHILD && !$twoWay ) { - $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey, $twoWay) { + $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { $related = $this->findOne($relatedCollection->getId(), [ Query::select(['$id']), Query::equal($twoWayKey, [$document->getId()]) @@ -8298,15 +8302,13 @@ private function deleteRestrict( return; } - $updated = $this->skipRelationships(fn () => $this->updateDocument( + $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), new Document([ $twoWayKey => null ]) )); - - $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); } @@ -8562,6 +8564,8 @@ private function deleteCascade(Document $collection, Document $relatedCollection * Delete Documents * * Deletes all documents which match the given query, will respect the relationship's onDelete optin. + * Unlike deleteDocument(), it does not fire EVENT_DOCUMENT_RELATED_UPDATE for the related + * documents it changes. * * @param string $collection * @param array $queries diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index cabc37972..a0151cb92 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -868,10 +868,9 @@ public function upsertDocuments( return $modified; } - public function deleteDocument(string $collection, string $id, ?callable $onRelated = null): bool + public function deleteDocument(string $collection, string $id): bool { - // Only the source reports related documents; the destination is a mirror of the same write. - $result = $this->source->deleteDocument($collection, $id, $onRelated); + $result = $this->source->deleteDocument($collection, $id); if ( \in_array($collection, self::SOURCE_ONLY_COLLECTIONS) diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 6b8948764..f0e6b83f5 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4932,10 +4932,10 @@ public function testOrderAndCursorWithRelationshipQueries(): void } /** - * deleteDocument() reports every document on the other side of a two-way relationship + * deleteDocument() fires an event 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 testDeleteDocumentRelatedCallback(): void + public function testDeleteDocumentRelatedUpdateEvent(): void { /** @var Database $database */ $database = $this->getDatabase(); @@ -4983,91 +4983,96 @@ public function testDeleteDocumentRelatedCallback(): void 'children' => ['child1', 'child2'], ])); - // Deleting the parent writes every child, so each one is reported with its - // reference already cleared. + // By id for looking a peer up, and in order so a peer fired twice fails the test. $reported = []; - $database->deleteDocument('related_parent', 'parent1', function (Document $related, Document $collection) use (&$reported) { + $fired = []; + $database->on(Database::EVENT_DOCUMENT_RELATED_UPDATE, 'related-test', function (string $event, Document $related) use (&$reported, &$fired) { $reported[$related->getId()] = $related; - $this->assertEquals('related_child', $collection->getId()); + $fired[] = $related->getId(); }); - $this->assertEqualsCanonicalizing(['child1', 'child2'], \array_keys($reported)); - - // A child read off the deleted parent carries no 'parent' key at all, so only the - // copy the set-null write returned can satisfy both of these. - $this->assertArrayHasKey('parent', $reported['child1']->getArrayCopy()); - $this->assertNull($reported['child1']->getAttribute('parent')); - - // Deleting a child writes nothing to the parent -- the foreign key lived on the - // deleted row -- but the parent's relationship changed, so it is still reported. - $database->createDocument('related_parent', new Document([ - '$id' => 'parent2', - '$permissions' => $documentPermissions, - 'children' => ['child1'], - ])); + // The Database is shared across the suite, so the listener must not outlive a failure. + try { + // Deleting the parent writes every child, so each one is reported with its + // reference already cleared. + $database->deleteDocument('related_parent', 'parent1'); + + $this->assertEqualsCanonicalizing(['child1', 'child2'], $fired); + $this->assertEquals('related_child', $reported['child1']->getCollection()); + + // A child read off the deleted parent carries no 'parent' key at all, so only the + // copy the set-null write returned can satisfy both of these. + $this->assertArrayHasKey('parent', $reported['child1']->getArrayCopy()); + $this->assertNull($reported['child1']->getAttribute('parent')); + + // Deleting a child writes nothing to the parent -- the foreign key lived on the + // deleted row -- but the parent's relationship changed, so it is still reported. + $database->createDocument('related_parent', new Document([ + '$id' => 'parent2', + '$permissions' => $documentPermissions, + 'children' => ['child1'], + ])); - $reported = []; - $database->deleteDocument('related_child', 'child1', function (Document $related) use (&$reported) { - $reported[] = $related->getId(); - }); + $fired = []; + $database->deleteDocument('related_child', 'child1'); - $this->assertEquals(['parent2'], $reported); + $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, - ); + // 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', - ])); + $database->createDocument('related_child', new Document([ + '$id' => 'child3', + '$permissions' => $documentPermissions, + 'parent' => 'parent2', + ])); - $reported = []; - $database->deleteDocument('related_parent', 'parent2', function (Document $related) use (&$reported) { - $reported[] = $related->getId(); - }); + $fired = []; + $database->deleteDocument('related_parent', 'parent2'); - $this->assertEquals([], $reported); - $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + $this->assertEquals([], $fired); + $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); - // A one-way peer exposes no relationship of its own, so clearing its internal - // foreign key changes nothing a caller can observe and it is not reported. - $database->createCollection('related_oneway', permissions: $collectionPermissions, documentSecurity: true); + // A one-way peer exposes no relationship of its own, so clearing its internal + // foreign key changes nothing a caller can observe and it is not reported. + $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_ONE_TO_MANY, + twoWay: false, + id: 'strays', + onDelete: Database::RELATION_MUTATE_SET_NULL, + ); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent3', - '$permissions' => $documentPermissions, - ])); + $database->createDocument('related_parent', new Document([ + '$id' => 'parent3', + '$permissions' => $documentPermissions, + ])); - $database->createDocument('related_oneway', new Document([ - '$id' => 'stray1', - '$permissions' => $documentPermissions, - ])); + $database->createDocument('related_oneway', new Document([ + '$id' => 'stray1', + '$permissions' => $documentPermissions, + ])); - $database->updateDocument('related_parent', 'parent3', new Document([ - 'strays' => ['stray1'], - ])); + $database->updateDocument('related_parent', 'parent3', new Document([ + 'strays' => ['stray1'], + ])); - $reported = []; - $database->deleteDocument('related_parent', 'parent3', function (Document $related) use (&$reported) { - $reported[] = $related->getId(); - }); + $fired = []; + $database->deleteDocument('related_parent', 'parent3'); - $this->assertEquals([], $reported); - $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + $this->assertEquals([], $fired); + $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + } finally { + $database->on(Database::EVENT_DOCUMENT_RELATED_UPDATE, 'related-test', null); + } $database->deleteCollection('related_parent'); $database->deleteCollection('related_child'); From 5ad94c667e1a497ced3a80f451001c1435a345d2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 12:41:03 +0530 Subject: [PATCH 12/16] test: assert which peers are reported, not what their payload holds The test required a literal 'parent' key on a reported peer, to tell the copy the set-null write returned from the one read off the deleted document. That pins down the one thing the contract tells a listener not to rely on - read a peer back for anything beyond its identity - so it tested an implementation detail rather than behaviour. What a listener can rely on is which peers arrive, once each, and which collection each belongs to, and that is what remains. --- tests/e2e/Adapter/Scopes/RelationshipTests.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index f0e6b83f5..42e03ef9f 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4993,18 +4993,12 @@ public function testDeleteDocumentRelatedUpdateEvent(): void // The Database is shared across the suite, so the listener must not outlive a failure. try { - // Deleting the parent writes every child, so each one is reported with its - // reference already cleared. + // Deleting the parent clears every child's reference, so each one is reported once. $database->deleteDocument('related_parent', 'parent1'); $this->assertEqualsCanonicalizing(['child1', 'child2'], $fired); $this->assertEquals('related_child', $reported['child1']->getCollection()); - // A child read off the deleted parent carries no 'parent' key at all, so only the - // copy the set-null write returned can satisfy both of these. - $this->assertArrayHasKey('parent', $reported['child1']->getArrayCopy()); - $this->assertNull($reported['child1']->getAttribute('parent')); - // Deleting a child writes nothing to the parent -- the foreign key lived on the // deleted row -- but the parent's relationship changed, so it is still reported. $database->createDocument('related_parent', new Document([ From 794f9f5ada820955076f5321b73ac803adefe1ab Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 12:53:44 +0530 Subject: [PATCH 13/16] refactor: fire the normal update event and carry peers as return values The documents a delete changes on the other side of a two-way relationship now go out as EVENT_DOCUMENT_UPDATE, so anything already listening for updates hears about them without a new event to subscribe to. They also no longer live on the instance. The relationship teardown returns the peers it left changed: set-null hands back the documents it wrote, cascade hands back the ids it removed, and the walk over the deleted document's relationships covers the peers nothing wrote to because the reference was on the deleted row. deleteDocument() fires them after its transaction, where the update events the teardown's own writes produce would otherwise be silenced. A cascade re-enters deleteDocument() with listeners silenced, so it gathers nothing of its own, and bulk deleteDocuments() ignores what the teardown returns. --- src/Database/Database.php | 275 ++++++------------ .../e2e/Adapter/Scopes/RelationshipTests.php | 13 +- 2 files changed, 103 insertions(+), 185 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 34986c34e..78aaf8dd1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -206,7 +206,6 @@ class Database public const EVENT_DOCUMENTS_UPDATE = 'documents_update'; public const EVENT_DOCUMENTS_UPSERT = 'documents_upsert'; public const EVENT_DOCUMENT_DELETE = 'document_delete'; - public const EVENT_DOCUMENT_RELATED_UPDATE = 'document_related_update'; public const EVENT_DOCUMENTS_DELETE = 'documents_delete'; public const EVENT_DOCUMENT_COUNT = 'document_count'; public const EVENT_DOCUMENT_SUM = 'document_sum'; @@ -486,23 +485,6 @@ class Database */ protected array $relationshipDeleteStack = []; - /** - * Documents on the other side of a relationship that a delete changed, keyed by - * collection and document id. Only collected while a delete that something is - * listening to is running. - * - * @var array|null - */ - protected ?array $relatedDocuments = null; - - /** - * Collection-and-id keys removed by the delete that is currently running, so - * cascaded documents are not reported as changed. - * - * @var array - */ - protected array $relatedDocumentsRemoved = []; - /** * Type mapping for collections to custom document classes * @var array> @@ -7977,37 +7959,7 @@ public function decreaseDocumentAttribute( /** * Delete Document * - * Fires EVENT_DOCUMENT_RELATED_UPDATE once per document on the other side of a two-way - * relationship whose relationship changed because of this delete, after this delete's - * own transaction ends. That covers documents this delete wrote, such as a set-null - * peer, and documents left holding a reference that is now gone, which are not written - * at all. Documents the delete cascaded away are not reported. The peers are only - * gathered when a listener that is not silenced is registered for that event. - * deleteDocuments() clears the same relationships but does not fire it. - * - * Like EVENT_DOCUMENT_DELETE, which fires just before it, the event is not deferred - * past an enclosing transaction. Wrapped in a caller's own withTransaction(), it fires - * before that transaction commits, again for every retried attempt, and whether or not - * the caller then rolls back. A listener that needs durable state should hold what it - * receives until that withTransaction() returns. A listener that throws propagates out - * of this call after the delete has committed, so the throw does not undo it; peers - * after it are not reported, and neither is any if an EVENT_DOCUMENT_DELETE listener - * throws first. - * - * The document a listener receives is the copy the delete itself worked with: read and - * written with permissions skipped, like the rest of the delete path, and handed over - * without a read check on the principal running the delete. A peer that principal - * cannot read still has its reference cleared, so it is still reported: whoever can - * read the peer is who needs to hear that it changed, and that is rarely whoever - * deleted the other side. Treat the listener as privileged. EVENT_DOCUMENT_DELETE is - * already handed these same peers inside the deleted document, read the same way. - * - * How the delete reached a peer decides the shape it arrives in. One the delete wrote - * is the copy that write returned, carrying the key it cleared. One it did not write - * is the copy read off the deleted document, and relationship population has already - * stripped the back-reference from it, so that key is absent rather than null. Either - * shape names its collection through getCollection(). Read a peer back if you need - * more of it than its identity. + * 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 @@ -8023,147 +7975,84 @@ public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - // Gather peers only when a listener would actually receive them, so every other delete - // keeps its memory. Relationship teardown runs with every listener silenced, so a - // cascade re-entering this method finds nobody to report to and keeps feeding the - // buffer it was handed. A separate delete started from a listener collects into its - // own buffer, and the one around it is restored below. - $collecting = $this->silentListeners !== null + // Only gather the related documents when a listener would hear about them. + $report = $this->silentListeners !== null && !empty(\array_diff_key( - ($this->listeners[self::EVENT_DOCUMENT_RELATED_UPDATE] ?? []) + $this->listeners[self::EVENT_ALL], + ($this->listeners[self::EVENT_DOCUMENT_UPDATE] ?? []) + $this->listeners[self::EVENT_ALL], $this->silentListeners, )); - $isolated = !$collecting && empty($this->relationshipDeleteStack); - $outerRelated = $this->relatedDocuments; - $outerRemoved = $this->relatedDocumentsRemoved; $related = []; - $removed = []; - - if ($isolated) { - $this->relatedDocuments = null; - } - - try { - $deleted = $this->withTransaction(function () use ($collection, $id, $collecting, &$document) { - // Reset inside the transaction so a retried attempt starts from an empty buffer. - if ($collecting) { - $this->relatedDocuments = []; - $this->relatedDocumentsRemoved = []; - } - - $document = $this->authorization->skip(fn () => $this->silent( - fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) - )); - - if ($document->isEmpty()) { - return false; - } - if ($collection->getId() !== self::METADATA) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); + $deleted = $this->withTransaction(function () use ($collection, $id, $report, &$document, &$related) { + $document = $this->authorization->skip(fn () => $this->silent( + fn () => $this->getDocument($collection->getId(), $id, forUpdate: true) + )); - if (!$this->authorization->isValid(new Input(self::PERMISSION_DELETE, [ - ...$collection->getDelete(), - ...($documentSecurity ? $document->getDelete() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); - } - } + if ($document->isEmpty()) { + return false; + } - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); - if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { - throw new ConflictException('Document was updated after the request timestamp'); + if (!$this->authorization->isValid(new Input(self::PERMISSION_DELETE, [ + ...$collection->getDelete(), + ...($documentSecurity ? $document->getDelete() : []) + ]))) { + throw new AuthorizationException($this->authorization->getDescription()); } + } - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); - } + // Check if document was updated after the request timestamp + try { + $oldUpdatedAt = new \DateTime($document->getUpdatedAt()); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } - $result = $this->adapter->deleteDocument($collection->getId(), $id); + if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } - if ($result && $this->relatedDocuments !== null) { - $this->relatedDocumentsRemoved[$this->relatedDocumentKey($collection->getId(), $id)] = true; - } + if ($this->resolveRelationships) { + $related = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document, $report)); + } - $this->purgeCachedDocument($collection->getId(), $id); + $result = $this->adapter->deleteDocument($collection->getId(), $id); - return $result; - }); - } finally { - if ($collecting) { - $related = $this->relatedDocuments ?? []; - $removed = $this->relatedDocumentsRemoved; - } + $this->purgeCachedDocument($collection->getId(), $id); - // A cascade leaves the buffer it was handed alone, so what it recorded and - // what it removed both survive into the report of the delete that called it. - if ($collecting || $isolated) { - $this->relatedDocuments = $outerRelated; - $this->relatedDocumentsRemoved = $outerRemoved; - } - } + return $result; + }); if ($deleted) { // 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); - // After this delete's own transaction, so a delete that rolled back on its own - // reports nothing. An enclosing caller transaction commits later, see above. - foreach ($related as $key => $peer) { - if (isset($removed[$key])) { - continue; - } - - $this->trigger(self::EVENT_DOCUMENT_RELATED_UPDATE, $peer); + foreach ($related as $relation) { + $this->trigger(self::EVENT_DOCUMENT_UPDATE, $relation); } } return $deleted; } - /** - * Record a document on the other side of a relationship that the running delete changed. - * - * A later call for the same document wins, so a document read from the deleted - * document's relationships is replaced by the copy the write returned. - * - * One-way peers are not recorded. The delete does clear their foreign key, but that - * key is internal and the peer exposes no relationship at all, so nothing a caller - * can observe about them changed. - */ - private function recordRelatedDocument(Document $collection, Document $document, bool $twoWay): void - { - if (!$twoWay || $this->relatedDocuments === null || $document->isEmpty()) { - return; - } - - $this->relatedDocuments[$this->relatedDocumentKey($collection->getId(), $document->getId())] = $document; - } - - private function relatedDocumentKey(string $collection, string $id): string - { - return $collection . ':' . $id; - } - /** * @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 = []; + $attributes = $collection->getAttribute('attributes', []); $relationships = \array_filter($attributes, function ($attribute) { @@ -8183,12 +8072,13 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $relationship->setAttribute('collection', $collection->getId()); $relationship->setAttribute('document', $document->getId()); - // Documents on the other side hold a reference to this one, so their relationship - // changes whether or not the delete writes to them. A write below replaces these - // with the copy it returned, and a cascade drops them again. - foreach (\is_array($value) ? $value : [$value] as $relation) { - if ($relation instanceof Document) { - $this->recordRelatedDocument($relatedCollection, $relation, $twoWay); + // The other side changes even when the delete never writes to it, because the + // reference it held pointed at the document being deleted. + if ($report && $twoWay) { + foreach (\is_array($value) ? $value : [$value] as $relation) { + if ($relation instanceof Document && !$relation->isEmpty()) { + $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; + } } } @@ -8197,7 +8087,9 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); break; case Database::RELATION_MUTATE_SET_NULL: - $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); + foreach ($this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side, $report && $twoWay) as $updated) { + $related[$relatedCollection->getId() . ':' . $updated->getId()] = $updated; + } break; case Database::RELATION_MUTATE_CASCADE: foreach ($this->relationshipDeleteStack as $processedRelationship) { @@ -8244,12 +8136,16 @@ private function deleteDocumentRelationships(Document $collection, Document $doc break 2; } } - $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); + foreach ($this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship) as $removed) { + unset($related[$relatedCollection->getId() . ':' . $removed]); + } break; } } - return $document; + unset($related[$collection->getId() . ':' . $document->getId()]); + + return $related; } /** @@ -8358,15 +8254,18 @@ private function findReferencingDocuments(Document $relatedCollection, Document * @param bool $twoWay * @param string $twoWayKey * @param string $side - * @return void + * @param bool $collect + * @return array The documents written, when $collect is set * @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, bool $collect = false): array { + $updated = []; + switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if (!$twoWay && $side === Database::RELATION_SIDE_PARENT) { @@ -8374,7 +8273,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, $twoWay) { + $result = $this->authorization->skip(function () use ($document, $relatedCollection, $twoWayKey) { $related = $this->findOne($relatedCollection->getId(), [ Query::select(['$id']), Query::equal($twoWayKey, [$document->getId()]) @@ -8384,16 +8283,18 @@ private function deleteSetNull(Document $collection, Document $relatedCollection return; } - $updated = $this->skipRelationships(fn () => $this->updateDocument( + return $this->skipRelationships(fn () => $this->updateDocument( $relatedCollection->getId(), $related->getId(), new Document([ $twoWayKey => null ]) )); - - $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); + + if ($collect && $result !== null) { + $updated[] = $result; + } break; case Database::RELATION_ONE_TO_MANY: @@ -8404,17 +8305,19 @@ 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, $twoWay) { - $updated = $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([ $twoWayKey => null ]), )); - - $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); + + if ($collect) { + $updated[] = $result; + } } break; @@ -8426,17 +8329,19 @@ 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, $twoWay) { - $updated = $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([ $twoWayKey => null ]) )); - - $this->recordRelatedDocument($relatedCollection, $updated, $twoWay); }); + + if ($collect) { + $updated[] = $result; + } } break; @@ -8457,6 +8362,8 @@ private function deleteSetNull(Document $collection, Document $relatedCollection } break; } + + return $updated; } /** @@ -8469,15 +8376,17 @@ private function deleteSetNull(Document $collection, Document $relatedCollection * @param string $twoWayKey * @param string $side * @param Document $relationship - * @return void + * @return array The ids of the related documents deleted * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException * @throws RestrictedException * @throws StructureException */ - private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, string $twoWayKey, string $side, Document $relationship): void + private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, string $twoWayKey, string $side, Document $relationship): array { + $removed = []; + switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if ($value !== null) { @@ -8487,6 +8396,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), ($value instanceof Document) ? $value->getId() : $value ); + $removed[] = ($value instanceof Document) ? $value->getId() : $value; \array_pop($this->relationshipDeleteStack); } @@ -8503,6 +8413,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $relation->getId() ); + $removed[] = $relation->getId(); } \array_pop($this->relationshipDeleteStack); @@ -8526,6 +8437,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $relation->getId() ); + $removed[] = $relation->getId(); } \array_pop($this->relationshipDeleteStack); @@ -8548,6 +8460,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $document->getAttribute($key) ); + $removed[] = $document->getAttribute($key); } $this->deleteDocument( $junction, @@ -8558,14 +8471,14 @@ private function deleteCascade(Document $collection, Document $relatedCollection \array_pop($this->relationshipDeleteStack); break; } + + return $removed; } /** * Delete Documents * * Deletes all documents which match the given query, will respect the relationship's onDelete optin. - * Unlike deleteDocument(), it does not fire EVENT_DOCUMENT_RELATED_UPDATE for the related - * documents it changes. * * @param string $collection * @param array $queries @@ -8677,7 +8590,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 42e03ef9f..3b32ef674 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4932,7 +4932,7 @@ public function testOrderAndCursorWithRelationshipQueries(): void } /** - * deleteDocument() fires an event for every document on the other side of a two-way relationship + * 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 @@ -4986,18 +4986,23 @@ public function testDeleteDocumentRelatedUpdateEvent(): void // By id for looking a peer up, and in order so a peer fired twice fails the test. $reported = []; $fired = []; - $database->on(Database::EVENT_DOCUMENT_RELATED_UPDATE, 'related-test', function (string $event, Document $related) use (&$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 every child's reference, so each one is reported once. + // Deleting the parent clears every child's reference, so each one 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 -- the foreign key lived on the // deleted row -- but the parent's relationship changed, so it is still reported. @@ -5065,7 +5070,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $this->assertEquals([], $fired); $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); } finally { - $database->on(Database::EVENT_DOCUMENT_RELATED_UPDATE, 'related-test', null); + $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', null); } $database->deleteCollection('related_parent'); From f7855f2bc5c206337e06fc5e9795e81761c38215 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 12:56:41 +0530 Subject: [PATCH 14/16] perf: skip gathering related documents for collections without two-way relationships A delete from a collection with no two-way relationship has nothing to report, so it no longer works out whether anyone is listening. Restrict relationships still count. Restrict only blocks a delete that would leave the other side holding a reference to nothing; deleting the child of a one-to-many, the parent of a many-to-one or the child of a many-to-many is allowed, and the document on the other side has still lost its reference. The test now deletes a child under restrict and expects its parent reported. --- src/Database/Database.php | 6 ++++- .../e2e/Adapter/Scopes/RelationshipTests.php | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 78aaf8dd1..93c4d12de 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7975,8 +7975,12 @@ public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - // Only gather the related documents when a listener would hear about them. + // Only gather the related documents when there can be some and a listener would hear about them. $report = $this->silentListeners !== null + && !empty(\array_filter( + $collection->getAttribute('attributes', []), + fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP && $attribute['options']['twoWay'], + )) && !empty(\array_diff_key( ($this->listeners[self::EVENT_DOCUMENT_UPDATE] ?? []) + $this->listeners[self::EVENT_ALL], $this->silentListeners, diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 3b32ef674..8e04b8c46 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5037,6 +5037,30 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $this->assertEquals([], $fired); $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + // Restrict only blocks a delete that would orphan something. Deleting a child is + // allowed, 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 of its own, so clearing its internal // foreign key changes nothing a caller can observe and it is not reported. $database->createCollection('related_oneway', permissions: $collectionPermissions, documentSecurity: true); From 6c08f533919947cc37c1861a5d7b87e0b5933fb4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 25 Sep 2026 17:10:34 +0530 Subject: [PATCH 15/16] refactor: switch on relationship type to find the peers a delete changed Set-null peers are the documents its update already returns. Peers left unwritten, because the deleted side held the key or the delete was restricted or many-to-many, come from the relationship values the delete already loaded. Peers another relationship cascades away are dropped. Nothing is fetched and there is no gate, so deleteCascade and the event listeners are untouched. --- src/Database/Database.php | 97 +++++++++---------- .../e2e/Adapter/Scopes/RelationshipTests.php | 71 +++++++++++--- 2 files changed, 105 insertions(+), 63 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 93c4d12de..cba41e051 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7975,19 +7975,9 @@ public function deleteDocument(string $collection, string $id): bool { $collection = $this->silent(fn () => $this->getCollection($collection)); - // Only gather the related documents when there can be some and a listener would hear about them. - $report = $this->silentListeners !== null - && !empty(\array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP && $attribute['options']['twoWay'], - )) - && !empty(\array_diff_key( - ($this->listeners[self::EVENT_DOCUMENT_UPDATE] ?? []) + $this->listeners[self::EVENT_ALL], - $this->silentListeners, - )); $related = []; - $deleted = $this->withTransaction(function () use ($collection, $id, $report, &$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) )); @@ -8019,7 +8009,7 @@ public function deleteDocument(string $collection, string $id): bool } if ($this->resolveRelationships) { - $related = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document, $report)); + $related = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); } $result = $this->adapter->deleteDocument($collection->getId(), $id); @@ -8045,17 +8035,17 @@ public function deleteDocument(string $collection, string $id): bool /** * @param Document $collection * @param Document $document - * @param bool $report - * @return array The two-way related documents left changed, when $report is set + * @return array The two-way related documents left changed * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException * @throws RestrictedException * @throws StructureException */ - private function deleteDocumentRelationships(Document $collection, Document $document, bool $report = false): array + private function deleteDocumentRelationships(Document $collection, Document $document): array { $related = []; + $deleted = [$collection->getId() . ':' . $document->getId() => true]; $attributes = $collection->getAttribute('attributes', []); @@ -8076,26 +8066,33 @@ private function deleteDocumentRelationships(Document $collection, Document $doc $relationship->setAttribute('collection', $collection->getId()); $relationship->setAttribute('document', $document->getId()); - // The other side changes even when the delete never writes to it, because the - // reference it held pointed at the document being deleted. - if ($report && $twoWay) { - foreach (\is_array($value) ? $value : [$value] as $relation) { - if ($relation instanceof Document && !$relation->isEmpty()) { - $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; - } - } - } + // 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: - foreach ($this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side, $report && $twoWay) as $updated) { - $related[$relatedCollection->getId() . ':' . $updated->getId()] = $updated; + $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']; @@ -8140,16 +8137,25 @@ private function deleteDocumentRelationships(Document $collection, Document $doc break 2; } } - foreach ($this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship) as $removed) { - unset($related[$relatedCollection->getId() . ':' . $removed]); - } + $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); break; } - } - unset($related[$collection->getId() . ':' . $document->getId()]); + foreach (\is_array($value) ? $value : [$value] as $relation) { + if (!$relation instanceof Document || $relation->isEmpty()) { + continue; + } + + // A peer reached through another relationship may be cascaded away by this one + if ($onDelete === Database::RELATION_MUTATE_CASCADE && !$unwritten) { + $deleted[$relatedCollection->getId() . ':' . $relation->getId()] = true; + } elseif ($twoWay && $unwritten) { + $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; + } + } + } - return $related; + return \array_diff_key($related, $deleted); } /** @@ -8258,15 +8264,14 @@ private function findReferencingDocuments(Document $relatedCollection, Document * @param bool $twoWay * @param string $twoWayKey * @param string $side - * @param bool $collect - * @return array The documents written, when $collect is set + * @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, bool $collect = false): array + private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, string $relationType, bool $twoWay, string $twoWayKey, string $side): array { $updated = []; @@ -8296,7 +8301,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - if ($collect && $result !== null) { + if ($result !== null) { $updated[] = $result; } break; @@ -8319,9 +8324,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - if ($collect) { - $updated[] = $result; - } + $updated[] = $result; } break; @@ -8343,9 +8346,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - if ($collect) { - $updated[] = $result; - } + $updated[] = $result; } break; @@ -8380,17 +8381,15 @@ private function deleteSetNull(Document $collection, Document $relatedCollection * @param string $twoWayKey * @param string $side * @param Document $relationship - * @return array The ids of the related documents deleted + * @return void * @throws AuthorizationException * @throws ConflictException * @throws DatabaseException * @throws RestrictedException * @throws StructureException */ - private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, string $twoWayKey, string $side, Document $relationship): array + private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, mixed $value, string $relationType, string $twoWayKey, string $side, Document $relationship): void { - $removed = []; - switch ($relationType) { case Database::RELATION_ONE_TO_ONE: if ($value !== null) { @@ -8400,7 +8399,6 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), ($value instanceof Document) ? $value->getId() : $value ); - $removed[] = ($value instanceof Document) ? $value->getId() : $value; \array_pop($this->relationshipDeleteStack); } @@ -8417,7 +8415,6 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $relation->getId() ); - $removed[] = $relation->getId(); } \array_pop($this->relationshipDeleteStack); @@ -8441,7 +8438,6 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $relation->getId() ); - $removed[] = $relation->getId(); } \array_pop($this->relationshipDeleteStack); @@ -8464,7 +8460,6 @@ private function deleteCascade(Document $collection, Document $relatedCollection $relatedCollection->getId(), $document->getAttribute($key) ); - $removed[] = $document->getAttribute($key); } $this->deleteDocument( $junction, @@ -8475,8 +8470,6 @@ private function deleteCascade(Document $collection, Document $relatedCollection \array_pop($this->relationshipDeleteStack); break; } - - return $removed; } /** diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 8e04b8c46..e5f20a87d 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4983,7 +4983,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void 'children' => ['child1', 'child2'], ])); - // By id for looking a peer up, and in order so a peer fired twice fails the test. + // 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) { @@ -4991,10 +4991,9 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $fired[] = $related->getId(); }); - // The Database is shared across the suite, so the listener must not outlive a failure. + // The Database is shared across the suite, so the listener must not outlive a failure try { - // Deleting the parent clears every child's reference, so each one is reported once, - // as the delete left it. + // 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); @@ -5004,8 +5003,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $reported['child1']->getUpdatedAt(), ); - // Deleting a child writes nothing to the parent -- the foreign key lived on the - // deleted row -- but the parent's relationship changed, so it is still reported. + // Deleting a child writes nothing to the parent, whose relationship still changed $database->createDocument('related_parent', new Document([ '$id' => 'parent2', '$permissions' => $documentPermissions, @@ -5018,7 +5016,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $this->assertEquals(['parent2'], $fired); $this->assertEquals('related_parent', $reported['parent2']->getCollection()); - // A cascaded document is gone, so it is not reported as changed. + // A cascaded document is gone, so it is not reported as changed $database->updateRelationship( collection: 'related_parent', id: 'children', @@ -5037,8 +5035,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $this->assertEquals([], $fired); $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); - // Restrict only blocks a delete that would orphan something. Deleting a child is - // allowed, and the parent still loses its reference to it. + // Restrict allows deleting a child, and the parent still loses its reference to it $database->updateRelationship( collection: 'related_parent', id: 'children', @@ -5061,8 +5058,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $this->assertEquals(['parent4'], $fired); - // A one-way peer exposes no relationship of its own, so clearing its internal - // foreign key changes nothing a caller can observe and it is not reported. + // 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( @@ -5074,6 +5070,16 @@ public function testDeleteDocumentRelatedUpdateEvent(): void 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, @@ -5086,6 +5092,7 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $database->updateDocument('related_parent', 'parent3', new Document([ 'strays' => ['stray1'], + 'stray' => 'stray1', ])); $fired = []; @@ -5093,6 +5100,47 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $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()); } finally { $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', null); } @@ -5100,5 +5148,6 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $database->deleteCollection('related_parent'); $database->deleteCollection('related_child'); $database->deleteCollection('related_oneway'); + $database->deleteCollection('related_pair'); } } From 451f2240530e4440ffb4406bd8a60cf0ad26427e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 28 Sep 2026 12:10:54 +0530 Subject: [PATCH 16/16] fix: drop related documents a cascade chain removed A peer can be removed further down a cascade chain than the delete sees, so an update fired for a document that no longer existed. When a cascade ran, only the related documents that still exist are reported, checked with one lookup per related collection. Only a delete a listener can hear does the lookup, so bulk and cascaded deletes cost nothing extra. A peer deleted concurrently comes back empty from its set-null update and is no longer reported. --- src/Database/Database.php | 61 ++++++++++++++++--- .../e2e/Adapter/Scopes/RelationshipTests.php | 46 ++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index cba41e051..6c2c3f488 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8009,7 +8009,9 @@ public function deleteDocument(string $collection, string $id): bool } if ($this->resolveRelationships) { - $related = $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); @@ -8035,17 +8037,18 @@ public function deleteDocument(string $collection, string $id): bool /** * @param Document $collection * @param Document $document - * @return array The two-way related documents left changed + * @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): array + private function deleteDocumentRelationships(Document $collection, Document $document, bool $report = false): array { $related = []; - $deleted = [$collection->getId() . ':' . $document->getId() => true]; + $cascaded = false; $attributes = $collection->getAttribute('attributes', []); @@ -8146,16 +8149,50 @@ private function deleteDocumentRelationships(Document $collection, Document $doc continue; } - // A peer reached through another relationship may be cascaded away by this one if ($onDelete === Database::RELATION_MUTATE_CASCADE && !$unwritten) { - $deleted[$relatedCollection->getId() . ':' . $relation->getId()] = true; + $cascaded = true; } elseif ($twoWay && $unwritten) { $related[$relatedCollection->getId() . ':' . $relation->getId()] = $relation; } } } - return \array_diff_key($related, $deleted); + 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()]), + ); } /** @@ -8301,7 +8338,7 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - if ($result !== null) { + if ($result !== null && !$result->isEmpty()) { $updated[] = $result; } break; @@ -8324,7 +8361,9 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - $updated[] = $result; + if (!$result->isEmpty()) { + $updated[] = $result; + } } break; @@ -8346,7 +8385,9 @@ private function deleteSetNull(Document $collection, Document $relatedCollection )); }); - $updated[] = $result; + if (!$result->isEmpty()) { + $updated[] = $result; + } } break; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index e5f20a87d..2e4ecf89f 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -5141,6 +5141,52 @@ public function testDeleteDocumentRelatedUpdateEvent(): void $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); }