From 61269866baf4734cdf1b1ac7e28e8b02fb15a8f5 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 8 Jan 2024 15:36:49 +0100 Subject: [PATCH] Update events to play nice with transactions (#2594) * Pass session and transaction information to event args * Only dispatch lifecycle events once per commit operation * Remove isInTransaction property in event args * Split method signature for readability * Use property promotion for event args classes * Extract construction of eventArgs * Inline spl_object_hash calls * Avoid injecting test instance * Add session to commitOptions in persister * Add session assertions in LifecycleEventManager --- .../ODM/MongoDB/Event/LifecycleEventArgs.php | 15 + .../ODM/MongoDB/Event/PreLoadEventArgs.php | 16 +- .../ODM/MongoDB/Event/PreUpdateEventArgs.php | 32 +- lib/Doctrine/ODM/MongoDB/MongoDBException.php | 5 + .../MongoDB/Persisters/DocumentPersister.php | 9 +- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 17 +- .../MongoDB/Utility/LifecycleEventManager.php | 143 +++++++-- .../TransactionalLifecycleEventsTest.php | 275 ++++++++++++++++++ 8 files changed, 455 insertions(+), 57 deletions(-) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php diff --git a/lib/Doctrine/ODM/MongoDB/Event/LifecycleEventArgs.php b/lib/Doctrine/ODM/MongoDB/Event/LifecycleEventArgs.php index 2d2866ca84..6184c0a4fb 100644 --- a/lib/Doctrine/ODM/MongoDB/Event/LifecycleEventArgs.php +++ b/lib/Doctrine/ODM/MongoDB/Event/LifecycleEventArgs.php @@ -6,6 +6,8 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\Persistence\Event\LifecycleEventArgs as BaseLifecycleEventArgs; +use Doctrine\Persistence\ObjectManager; +use MongoDB\Driver\Session; /** * Lifecycle Events are triggered by the UnitOfWork during lifecycle transitions @@ -15,6 +17,14 @@ */ class LifecycleEventArgs extends BaseLifecycleEventArgs { + public function __construct( + object $object, + ObjectManager $objectManager, + public readonly ?Session $session = null, + ) { + parent::__construct($object, $objectManager); + } + public function getDocument(): object { return $this->getObject(); @@ -24,4 +34,9 @@ public function getDocumentManager(): DocumentManager { return $this->getObjectManager(); } + + public function isInTransaction(): bool + { + return $this->session?->isInTransaction() ?? false; + } } diff --git a/lib/Doctrine/ODM/MongoDB/Event/PreLoadEventArgs.php b/lib/Doctrine/ODM/MongoDB/Event/PreLoadEventArgs.php index 39db5cd0a9..a30f705f29 100644 --- a/lib/Doctrine/ODM/MongoDB/Event/PreLoadEventArgs.php +++ b/lib/Doctrine/ODM/MongoDB/Event/PreLoadEventArgs.php @@ -5,21 +5,21 @@ namespace Doctrine\ODM\MongoDB\Event; use Doctrine\ODM\MongoDB\DocumentManager; +use MongoDB\Driver\Session; /** * Class that holds event arguments for a preLoad event. */ final class PreLoadEventArgs extends LifecycleEventArgs { - /** @var array */ - private array $data; - /** @param array $data */ - public function __construct(object $document, DocumentManager $dm, array &$data) - { - parent::__construct($document, $dm); - - $this->data =& $data; + public function __construct( + object $document, + DocumentManager $dm, + private array &$data, + ?Session $session = null, + ) { + parent::__construct($document, $dm, $session); } /** diff --git a/lib/Doctrine/ODM/MongoDB/Event/PreUpdateEventArgs.php b/lib/Doctrine/ODM/MongoDB/Event/PreUpdateEventArgs.php index a2385cb757..f01dff1a56 100644 --- a/lib/Doctrine/ODM/MongoDB/Event/PreUpdateEventArgs.php +++ b/lib/Doctrine/ODM/MongoDB/Event/PreUpdateEventArgs.php @@ -7,6 +7,7 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\UnitOfWork; use InvalidArgumentException; +use MongoDB\Driver\Session; use function get_class; use function sprintf; @@ -18,26 +19,27 @@ */ final class PreUpdateEventArgs extends LifecycleEventArgs { - /** @psalm-var array */ - private array $documentChangeSet; - /** @psalm-param array $changeSet */ - public function __construct(object $document, DocumentManager $dm, array $changeSet) - { - parent::__construct($document, $dm); - - $this->documentChangeSet = $changeSet; + public function __construct( + object $document, + DocumentManager $dm, + private array $changeSet, + ?Session $session = null, + ) { + parent::__construct($document, $dm, $session); + + $this->changeSet = $changeSet; } /** @return array */ public function getDocumentChangeSet(): array { - return $this->documentChangeSet; + return $this->changeSet; } public function hasChangedField(string $field): bool { - return isset($this->documentChangeSet[$field]); + return isset($this->changeSet[$field]); } /** @@ -49,7 +51,7 @@ public function getOldValue(string $field) { $this->assertValidField($field); - return $this->documentChangeSet[$field][0]; + return $this->changeSet[$field][0]; } /** @@ -61,7 +63,7 @@ public function getNewValue(string $field) { $this->assertValidField($field); - return $this->documentChangeSet[$field][1]; + return $this->changeSet[$field][1]; } /** @@ -73,8 +75,8 @@ public function setNewValue(string $field, $value): void { $this->assertValidField($field); - $this->documentChangeSet[$field][1] = $value; - $this->getDocumentManager()->getUnitOfWork()->setDocumentChangeSet($this->getDocument(), $this->documentChangeSet); + $this->changeSet[$field][1] = $value; + $this->getDocumentManager()->getUnitOfWork()->setDocumentChangeSet($this->getDocument(), $this->changeSet); } /** @@ -84,7 +86,7 @@ public function setNewValue(string $field, $value): void */ private function assertValidField(string $field): void { - if (! isset($this->documentChangeSet[$field])) { + if (! isset($this->changeSet[$field])) { throw new InvalidArgumentException(sprintf( 'Field "%s" is not a valid field of the document "%s" in PreUpdateEventArgs.', $field, diff --git a/lib/Doctrine/ODM/MongoDB/MongoDBException.php b/lib/Doctrine/ODM/MongoDB/MongoDBException.php index 620bcc86d8..a9d14a140e 100644 --- a/lib/Doctrine/ODM/MongoDB/MongoDBException.php +++ b/lib/Doctrine/ODM/MongoDB/MongoDBException.php @@ -155,4 +155,9 @@ public static function cannotCreateRepository(string $className): self { return new self(sprintf('Cannot create repository for class "%s".', $className)); } + + public static function transactionalSessionMismatch(): self + { + return new self('The transactional operation cannot be executed because it was started in a different session.'); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php index 48ff319b55..2a3a767310 100644 --- a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php +++ b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php @@ -74,7 +74,14 @@ * * @template T of object * - * @psalm-import-type CommitOptions from UnitOfWork + * @psalm-type CommitOptions array{ + * fsync?: bool, + * safe?: int, + * session?: ?Session, + * w?: int, + * withTransaction?: bool, + * writeConcern?: WriteConcern + * } * @psalm-import-type Hints from UnitOfWork * @psalm-import-type FieldMapping from ClassMetadata * @psalm-import-type SortMeta from Sort diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index 8c6748a8b9..60fc7b23f9 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -456,8 +456,12 @@ public function commit(array $options = []): void $this->evm->dispatchEvent(Events::onFlush, new Event\OnFlushEventArgs($this->dm)); if ($this->useTransaction($options)) { + $session = $this->dm->getClient()->startSession(); + + $this->lifecycleEventManager->enableTransactionalMode($session); + with_transaction( - $this->dm->getClient()->startSession(), + $session, function (Session $session) use ($options): void { $this->doCommit(['session' => $session] + $this->stripTransactionOptions($options)); }, @@ -484,6 +488,7 @@ function (Session $session) use ($options): void { $this->hasScheduledCollections = []; } finally { $this->commitsInProgress--; + $this->lifecycleEventManager->clearTransactionalState(); } } @@ -1171,7 +1176,7 @@ private function executeInserts(ClassMetadata $class, array $documents, array $o $persister->executeInserts($options); foreach ($documents as $document) { - $this->lifecycleEventManager->postPersist($class, $document); + $this->lifecycleEventManager->postPersist($class, $document, $options['session'] ?? null); } } @@ -1195,7 +1200,7 @@ private function executeUpserts(ClassMetadata $class, array $documents, array $o $persister->executeUpserts($options); foreach ($documents as $document) { - $this->lifecycleEventManager->postPersist($class, $document); + $this->lifecycleEventManager->postPersist($class, $document, $options['session'] ?? null); } } @@ -1218,13 +1223,13 @@ private function executeUpdates(ClassMetadata $class, array $documents, array $o $persister = $this->getDocumentPersister($className); foreach ($documents as $oid => $document) { - $this->lifecycleEventManager->preUpdate($class, $document); + $this->lifecycleEventManager->preUpdate($class, $document, $options['session'] ?? null); if (! empty($this->documentChangeSets[$oid]) || $this->hasScheduledCollections($document)) { $persister->update($document, $options); } - $this->lifecycleEventManager->postUpdate($class, $document); + $this->lifecycleEventManager->postUpdate($class, $document, $options['session'] ?? null); } } @@ -1266,7 +1271,7 @@ private function executeDeletions(ClassMetadata $class, array $documents, array $value->clearSnapshot(); } - $this->lifecycleEventManager->postRemove($class, $document); + $this->lifecycleEventManager->postRemove($class, $document, $options['session'] ?? null); } } diff --git a/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php b/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php index 46cbf3d17a..c6319fc2bf 100644 --- a/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php +++ b/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php @@ -13,16 +13,40 @@ use Doctrine\ODM\MongoDB\Event\PreUpdateEventArgs; use Doctrine\ODM\MongoDB\Events; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\MongoDBException; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface; use Doctrine\ODM\MongoDB\UnitOfWork; +use MongoDB\Driver\Session; + +use function spl_object_hash; /** @internal */ final class LifecycleEventManager { + private bool $transactionalModeEnabled = false; + + private ?Session $session = null; + + /** @var array> */ + private array $transactionalEvents = []; + public function __construct(private DocumentManager $dm, private UnitOfWork $uow, private EventManager $evm) { } + public function clearTransactionalState(): void + { + $this->transactionalModeEnabled = false; + $this->session = null; + $this->transactionalEvents = []; + } + + public function enableTransactionalMode(Session $session): void + { + $this->transactionalModeEnabled = true; + $this->session = $session; + } + /** * @param mixed $id * @@ -55,11 +79,17 @@ public function postCollectionLoad(PersistentCollectionInterface $coll): void * * @template T of object */ - public function postPersist(ClassMetadata $class, object $document): void + public function postPersist(ClassMetadata $class, object $document, ?Session $session = null): void { - $class->invokeLifecycleCallbacks(Events::postPersist, $document, [new LifecycleEventArgs($document, $this->dm)]); - $this->dispatchEvent($class, Events::postPersist, new LifecycleEventArgs($document, $this->dm)); - $this->cascadePostPersist($class, $document); + if (! $this->shouldDispatchEvent($document, Events::postPersist, $session)) { + return; + } + + $eventArgs = new LifecycleEventArgs($document, $this->dm, $session); + + $class->invokeLifecycleCallbacks(Events::postPersist, $document, [$eventArgs]); + $this->dispatchEvent($class, Events::postPersist, $eventArgs); + $this->cascadePostPersist($class, $document, $session); } /** @@ -70,10 +100,16 @@ public function postPersist(ClassMetadata $class, object $document): void * * @template T of object */ - public function postRemove(ClassMetadata $class, object $document): void + public function postRemove(ClassMetadata $class, object $document, ?Session $session = null): void { - $class->invokeLifecycleCallbacks(Events::postRemove, $document, [new LifecycleEventArgs($document, $this->dm)]); - $this->dispatchEvent($class, Events::postRemove, new LifecycleEventArgs($document, $this->dm)); + if (! $this->shouldDispatchEvent($document, Events::postRemove, $session)) { + return; + } + + $eventArgs = new LifecycleEventArgs($document, $this->dm, $session); + + $class->invokeLifecycleCallbacks(Events::postRemove, $document, [$eventArgs]); + $this->dispatchEvent($class, Events::postRemove, $eventArgs); } /** @@ -85,11 +121,17 @@ public function postRemove(ClassMetadata $class, object $document): void * * @template T of object */ - public function postUpdate(ClassMetadata $class, object $document): void + public function postUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { - $class->invokeLifecycleCallbacks(Events::postUpdate, $document, [new LifecycleEventArgs($document, $this->dm)]); - $this->dispatchEvent($class, Events::postUpdate, new LifecycleEventArgs($document, $this->dm)); - $this->cascadePostUpdate($class, $document); + if (! $this->shouldDispatchEvent($document, Events::postUpdate, $session)) { + return; + } + + $eventArgs = new LifecycleEventArgs($document, $this->dm, $session); + + $class->invokeLifecycleCallbacks(Events::postUpdate, $document, [$eventArgs]); + $this->dispatchEvent($class, Events::postUpdate, $eventArgs); + $this->cascadePostUpdate($class, $document, $session); } /** @@ -102,8 +144,14 @@ public function postUpdate(ClassMetadata $class, object $document): void */ public function prePersist(ClassMetadata $class, object $document): void { - $class->invokeLifecycleCallbacks(Events::prePersist, $document, [new LifecycleEventArgs($document, $this->dm)]); - $this->dispatchEvent($class, Events::prePersist, new LifecycleEventArgs($document, $this->dm)); + if (! $this->shouldDispatchEvent($document, Events::prePersist, null)) { + return; + } + + $eventArgs = new LifecycleEventArgs($document, $this->dm); + + $class->invokeLifecycleCallbacks(Events::prePersist, $document, [$eventArgs]); + $this->dispatchEvent($class, Events::prePersist, $eventArgs); } /** @@ -116,8 +164,14 @@ public function prePersist(ClassMetadata $class, object $document): void */ public function preRemove(ClassMetadata $class, object $document): void { - $class->invokeLifecycleCallbacks(Events::preRemove, $document, [new LifecycleEventArgs($document, $this->dm)]); - $this->dispatchEvent($class, Events::preRemove, new LifecycleEventArgs($document, $this->dm)); + if (! $this->shouldDispatchEvent($document, Events::preRemove, null)) { + return; + } + + $eventArgs = new LifecycleEventArgs($document, $this->dm); + + $class->invokeLifecycleCallbacks(Events::preRemove, $document, [$eventArgs]); + $this->dispatchEvent($class, Events::preRemove, $eventArgs); } /** @@ -128,15 +182,24 @@ public function preRemove(ClassMetadata $class, object $document): void * * @template T of object */ - public function preUpdate(ClassMetadata $class, object $document): void + public function preUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { + if (! $this->shouldDispatchEvent($document, Events::preUpdate, $session)) { + return; + } + + $eventArgs = new PreUpdateEventArgs($document, $this->dm, $this->uow->getDocumentChangeSet($document), $session); if (! empty($class->lifecycleCallbacks[Events::preUpdate])) { - $class->invokeLifecycleCallbacks(Events::preUpdate, $document, [new PreUpdateEventArgs($document, $this->dm, $this->uow->getDocumentChangeSet($document))]); + $class->invokeLifecycleCallbacks(Events::preUpdate, $document, [$eventArgs]); $this->uow->recomputeSingleDocumentChangeSet($class, $document); } - $this->dispatchEvent($class, Events::preUpdate, new PreUpdateEventArgs($document, $this->dm, $this->uow->getDocumentChangeSet($document))); - $this->cascadePreUpdate($class, $document); + $this->dispatchEvent( + $class, + Events::preUpdate, + new PreUpdateEventArgs($document, $this->dm, $this->uow->getDocumentChangeSet($document), $session), + ); + $this->cascadePreUpdate($class, $document, $session); } /** @@ -147,7 +210,7 @@ public function preUpdate(ClassMetadata $class, object $document): void * * @template T of object */ - private function cascadePreUpdate(ClassMetadata $class, object $document): void + private function cascadePreUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { $value = $class->reflFields[$mapping['fieldName']]->getValue($document); @@ -162,7 +225,7 @@ private function cascadePreUpdate(ClassMetadata $class, object $document): void continue; } - $this->preUpdate($this->dm->getClassMetadata($entry::class), $entry); + $this->preUpdate($this->dm->getClassMetadata($entry::class), $entry, $session); } } } @@ -175,7 +238,7 @@ private function cascadePreUpdate(ClassMetadata $class, object $document): void * * @template T of object */ - private function cascadePostUpdate(ClassMetadata $class, object $document): void + private function cascadePostUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { $value = $class->reflFields[$mapping['fieldName']]->getValue($document); @@ -192,10 +255,17 @@ private function cascadePostUpdate(ClassMetadata $class, object $document): void $entryClass = $this->dm->getClassMetadata($entry::class); $event = $this->uow->isScheduledForInsert($entry) ? Events::postPersist : Events::postUpdate; - $entryClass->invokeLifecycleCallbacks($event, $entry, [new LifecycleEventArgs($entry, $this->dm)]); - $this->dispatchEvent($entryClass, $event, new LifecycleEventArgs($entry, $this->dm)); - $this->cascadePostUpdate($entryClass, $entry); + if (! $this->shouldDispatchEvent($entry, $event, $session)) { + continue; + } + + $eventArgs = new LifecycleEventArgs($entry, $this->dm, $session); + + $entryClass->invokeLifecycleCallbacks($event, $entry, [$eventArgs]); + $this->dispatchEvent($entryClass, $event, $eventArgs); + + $this->cascadePostUpdate($entryClass, $entry, $session); } } } @@ -208,7 +278,7 @@ private function cascadePostUpdate(ClassMetadata $class, object $document): void * * @template T of object */ - private function cascadePostPersist(ClassMetadata $class, object $document): void + private function cascadePostPersist(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { $value = $class->reflFields[$mapping['fieldName']]->getValue($document); @@ -218,7 +288,7 @@ private function cascadePostPersist(ClassMetadata $class, object $document): voi $values = $mapping['type'] === ClassMetadata::ONE ? [$value] : $value; foreach ($values as $embeddedDocument) { - $this->postPersist($this->dm->getClassMetadata($embeddedDocument::class), $embeddedDocument); + $this->postPersist($this->dm->getClassMetadata($embeddedDocument::class), $embeddedDocument, $session); } } } @@ -232,4 +302,23 @@ private function dispatchEvent(ClassMetadata $class, string $eventName, ?EventAr $this->evm->dispatchEvent($eventName, $eventArgs); } + + private function shouldDispatchEvent(object $document, string $eventName, ?Session $session): bool + { + if (! $this->transactionalModeEnabled) { + return true; + } + + if ($session !== $this->session) { + throw MongoDBException::transactionalSessionMismatch(); + } + + // Check whether the event has already been dispatched. + $hasDispatched = isset($this->transactionalEvents[spl_object_hash($document)][$eventName]); + + // Mark the event as dispatched - no problem doing this if it already was dispatched + $this->transactionalEvents[spl_object_hash($document)][$eventName] = true; + + return ! $hasDispatched; + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php new file mode 100644 index 0000000000..f5bddaf2a3 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php @@ -0,0 +1,275 @@ +skipTestIfTransactionalFlushDisabled(); + } + + public function tearDown(): void + { + $this->dm->getClient()->selectDatabase('admin')->command([ + 'configureFailPoint' => 'failCommand', + 'mode' => 'off', + ]); + + parent::tearDown(); + } + + public function testPersistEvents(): void + { + $root = new RootEventDocument(); + $root->name = 'root'; + + $root->embedded = new EmbeddedEventDocument(); + $root->embedded->name = 'embedded'; + + $this->createFailPoint('insert'); + + $this->dm->persist($root); + $this->dm->flush(); + + $this->assertSame(1, $root->postPersist); + $this->assertSame(1, $root->embedded->postPersist); + } + + public function testUpdateEvents(): void + { + $root = new RootEventDocument(); + $root->name = 'root'; + + $root->embedded = new EmbeddedEventDocument(); + $root->embedded->name = 'embedded'; + + $this->dm->persist($root); + $this->dm->flush(); + + $this->createFailPoint('update'); + + $root->name = 'updated'; + $root->embedded->name = 'updated'; + + $this->dm->flush(); + + $this->assertSame(1, $root->preUpdate); + $this->assertSame(1, $root->postUpdate); + $this->assertSame(1, $root->embedded->preUpdate); + $this->assertSame(1, $root->embedded->postUpdate); + } + + public function testUpdateEventsRootOnly(): void + { + $root = new RootEventDocument(); + $root->name = 'root'; + + $root->embedded = new EmbeddedEventDocument(); + $root->embedded->name = 'embedded'; + + $this->dm->persist($root); + $this->dm->flush(); + + $this->createFailPoint('update'); + + $root->name = 'updated'; + + $this->dm->flush(); + + $this->assertSame(1, $root->preUpdate); + $this->assertSame(1, $root->postUpdate); + $this->assertSame(0, $root->embedded->preUpdate); + $this->assertSame(0, $root->embedded->postUpdate); + } + + public function testUpdateEventsEmbeddedOnly(): void + { + $root = new RootEventDocument(); + $root->name = 'root'; + + $root->embedded = new EmbeddedEventDocument(); + $root->embedded->name = 'embedded'; + + $this->dm->persist($root); + $this->dm->flush(); + + $this->createFailPoint('update'); + + $root->embedded->name = 'updated'; + + $this->dm->flush(); + + $this->assertSame(1, $root->preUpdate); + $this->assertSame(1, $root->postUpdate); + + $this->assertSame(1, $root->embedded->preUpdate); + $this->assertSame(1, $root->embedded->postUpdate); + } + + public function testUpdateEventsWithNewEmbeddedDocument(): void + { + $firstEmbedded = new EmbeddedEventDocument(); + $firstEmbedded->name = 'embedded'; + + $secondEmbedded = new EmbeddedEventDocument(); + $secondEmbedded->name = 'new'; + + $root = new RootEventDocument(); + $root->name = 'root'; + $root->embedded = $firstEmbedded; + + $this->dm->persist($root); + $this->dm->flush(); + + $this->createFailPoint('update'); + + $root->name = 'updated'; + $root->embedded = $secondEmbedded; + + $this->dm->flush(); + + $this->assertSame(1, $root->preUpdate); + $this->assertSame(1, $root->postUpdate); + + // First embedded document was removed but not updated + $this->assertSame(1, $firstEmbedded->postRemove); + $this->assertSame(0, $firstEmbedded->preUpdate); + $this->assertSame(0, $firstEmbedded->postUpdate); + + // Second embedded document was persisted but not updated + $this->assertSame(1, $secondEmbedded->postPersist); + $this->assertSame(0, $secondEmbedded->preUpdate); + $this->assertSame(0, $secondEmbedded->postUpdate); + } + + public function testRemoveEvents(): void + { + $root = new RootEventDocument(); + $root->name = 'root'; + + $root->embedded = new EmbeddedEventDocument(); + $root->embedded->name = 'embedded'; + + $this->dm->persist($root); + $this->dm->flush(); + + $this->createFailPoint('delete'); + + $this->dm->remove($root); + $this->dm->flush(); + + $this->assertSame(1, $root->postRemove); + $this->assertSame(1, $root->embedded->postRemove); + } + + /** Create a document manager with a single host to ensure failpoints target the correct server */ + protected static function createTestDocumentManager(): DocumentManager + { + $config = static::getConfiguration(); + $client = new Client(self::getUri(false), [], ['typeMap' => ['root' => 'array', 'document' => 'array']]); + + return DocumentManager::create($client, $config); + } + + private function createFailPoint(string $failCommand): void + { + $this->dm->getClient()->selectDatabase('admin')->command([ + 'configureFailPoint' => 'failCommand', + 'mode' => ['times' => 1], + 'data' => [ + 'errorCode' => 192, // FailPointEnabled + 'errorLabels' => ['TransientTransactionError'], + 'failCommands' => [$failCommand], + ], + ]); + } +} + +/** + * @ODM\MappedSuperclass + * @ODM\HasLifecycleCallbacks + */ +abstract class BaseEventDocument +{ + public function __construct() + { + } + + /** + * @ODM\Field(type="string") + * + * @var string|null + */ + public $name; + + public int $preUpdate = 0; + + public int $postPersist = 0; + + public int $postUpdate = 0; + + public int $postRemove = 0; + + /** @ODM\PreUpdate */ + public function preUpdate(Event\PreUpdateEventArgs $e): void + { + $this->assertTransactionState($e); + $this->preUpdate++; + } + + /** @ODM\PostPersist */ + public function postPersist(Event\LifecycleEventArgs $e): void + { + $this->assertTransactionState($e); + $this->postPersist++; + } + + /** @ODM\PostUpdate */ + public function postUpdate(Event\LifecycleEventArgs $e): void + { + $this->assertTransactionState($e); + $this->postUpdate++; + } + + /** @ODM\PostRemove */ + public function postRemove(Event\LifecycleEventArgs $e): void + { + $this->assertTransactionState($e); + $this->postRemove++; + } + + private function assertTransactionState(LifecycleEventArgs $e): void + { + Assert::assertTrue($e->isInTransaction()); + Assert::assertInstanceOf(Session::class, $e->session); + } +} + +/** @ODM\EmbeddedDocument */ +class EmbeddedEventDocument extends BaseEventDocument +{ +} + +/** @ODM\Document */ +class RootEventDocument extends BaseEventDocument +{ + /** @ODM\Id */ + public string $id; + + /** @ODM\EmbedOne(targetDocument=EmbeddedEventDocument::class) */ + public ?EmbeddedEventDocument $embedded; +}