Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,39 @@ If you'd like your factory to not persist by default, override its ``initialize(
Now, after creating objects using this factory, you'd have to call ``\Zenstruck\Foundry\Persistence\save()`` to actually
persist them to the database.

.. _without-doctrine-events:

Without Doctrine Events
~~~~~~~~~~~~~~~~~~~~~~~

When loading fixtures, Doctrine event listeners fire as usual and can cause unwanted side effects. You can disable
them during object creation to avoid this.

::

use App\Entity\Post;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\Persistence\persistent_factory;

// disable ALL Doctrine event listeners during creation
$post = PostFactory::new()->withoutDoctrineEvents()->create(); // returns Post

// disable only a specific listener/subscriber
$post = PostFactory::new()->withoutDoctrineEvents(PostListener::class)->create(); // returns Post

$posts = PostFactory::new()->withoutDoctrineEvents()->many(5)->create(); // returns Post[]

If you'd like your factory to always disable Doctrine events, override its ``initialize()`` method:

::

protected function initialize(): static
{
return $this
->withoutDoctrineEvents()
;
}

Array factories
~~~~~~~~~~~~~~~

Expand Down
233 changes: 226 additions & 7 deletions src/Persistence/PersistenceManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Zenstruck\Foundry\Persistence;

use Doctrine\Common\EventManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectRepository;
Expand Down Expand Up @@ -39,6 +41,24 @@ final class PersistenceManager
/** @var list<callable():bool> */
private array $afterPersistCallbacks = [];

/**
* Event listener classes to keep disabled until the next real flush, accumulated across
* multiple scheduleForInsert() calls inside a flush_after() context.
* Keyed by spl_object_id of the ObjectManager.
*
* @var array<int, list<class-string>|null>
*/
private array $pendingEventClassesToDisable = [];

/**
* Entity-listener overrides to apply during the next real flush, accumulated across
* multiple scheduleForInsert() calls inside a flush_after() context.
* Keyed by spl_object_id of the ObjectManager.
*
* @var array<int, list<array{entityClass: class-string, disabledClasses: list<class-string>|null}>>
*/
private array $pendingEntityListenerOverrides = [];

/**
* @param iterable<PersistenceStrategy> $strategies
*/
Expand Down Expand Up @@ -66,19 +86,34 @@ public function enablePersisting(): void
/**
* @template T of object
*
* @param T $object
* @param T $object
* @param list<class-string>|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled
*
* @return T
*/
public function save(object $object): object
public function save(object $object, ?array $disabledDoctrineEventClasses = null): object
{
if ($object instanceof Proxy) {
return $object->_save();
}

$om = $this->strategyFor($object::class)->objectManagerFor($object::class);

// Disable for prePersist (fires during persist())
// Note: disableEntityListeners must be called first, as it calls getClassMetadata() which
// triggers the loadClassMetadata event to register #[AsEntityListener] listeners. If we
// disabled global events first, that event would fire without the EntityListenerRegistry
// handler, leaving entityListeners permanently empty in the metadata cache.
$entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses);
$removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses);

$om->persist($object);
$this->flush($om);

$this->restoreDoctrineEvents($om, $removedListeners);
$this->restoreEntityListeners($om, $object::class, $entityListenerBackup);

// Disable for postPersist / preUpdate (fire during flush())
$this->flush($om, $disabledDoctrineEventClasses);

$shouldFlush = $this->callPostPersistCallbacks();

Expand All @@ -94,18 +129,41 @@ public function save(object $object): object
*
* @param T $object
* @param list<callable():bool> $afterPersistCallbacks
* @param list<class-string>|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled
*
* @return T
*/
public function scheduleForInsert(object $object, array $afterPersistCallbacks = []): object
public function scheduleForInsert(object $object, array $afterPersistCallbacks = [], ?array $disabledDoctrineEventClasses = null): object
{
if ($object instanceof Proxy) {
$object = ProxyGenerator::unwrap($object);
}

$om = $this->strategyFor($object::class)->objectManagerFor($object::class);

// Disable for prePersist (fires during persist())
// Note: disableEntityListeners must be called first — same reason as in save().
$entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses);
$removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses);

$om->persist($object);

$this->restoreDoctrineEvents($om, $removedListeners);
$this->restoreEntityListeners($om, $object::class, $entityListenerBackup);

// Accumulate for postPersist / preUpdate (fire during the deferred flush())
if (null !== $disabledDoctrineEventClasses) {
$omId = spl_object_id($om);
$this->pendingEventClassesToDisable[$omId] = $this->mergeEventClasses(
$this->pendingEventClassesToDisable[$omId] ?? null,
$disabledDoctrineEventClasses,
);
$this->pendingEntityListenerOverrides[$omId][] = [
'entityClass' => $object::class,
'disabledClasses' => $disabledDoctrineEventClasses,
];
}

$this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks];

return $object;
Expand Down Expand Up @@ -137,10 +195,40 @@ public function flushAfter(callable $callback): mixed
return $result;
}

public function flush(ObjectManager $om): void
/**
* @param list<class-string>|null $eventClassesToDisable
*/
public function flush(ObjectManager $om, ?array $eventClassesToDisable = null): void
{
if ($this->flush) {
$om->flush();
if (!$this->flush) {
return;
}

$omId = spl_object_id($om);

// Merge caller-supplied classes with anything accumulated from scheduleForInsert()
/** @var list<class-string>|null $pendingClasses */
$pendingClasses = $this->pendingEventClassesToDisable[$omId] ?? null;
$eventClassesToDisable = $this->mergeEventClasses($eventClassesToDisable, $pendingClasses);
unset($this->pendingEventClassesToDisable[$omId]);

$removedListeners = $this->disableDoctrineEvents($om, $eventClassesToDisable);

// Apply entity-listener overrides accumulated from scheduleForInsert()
$entityListenerBackups = [];
foreach ($this->pendingEntityListenerOverrides[$omId] ?? [] as $override) {
$entityListenerBackups[] = [
'entityClass' => $override['entityClass'],
'backup' => $this->disableEntityListeners($om, $override['entityClass'], $override['disabledClasses']),
];
}
unset($this->pendingEntityListenerOverrides[$omId]);

$om->flush();

$this->restoreDoctrineEvents($om, $removedListeners);
foreach ($entityListenerBackups as ['entityClass' => $entityClass, 'backup' => $backup]) {
$this->restoreEntityListeners($om, $entityClass, $backup);
}
}

Expand Down Expand Up @@ -469,6 +557,137 @@ private function callPostPersistCallbacks(): bool
return $shouldFlush;
}

/**
* Temporarily removes Doctrine event listeners from the EventManager.
*
* @param list<class-string>|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed
*
* @return array<string, list<object>> map of eventName => removed listeners, for later restoration
*/
private function disableDoctrineEvents(ObjectManager $om, ?array $eventClassesToDisable): array
{
if (null === $eventClassesToDisable || !method_exists($om, 'getEventManager')) {
return [];
}

/** @var EventManager $eventManager */
$eventManager = $om->getEventManager();
$removed = [];

foreach ($eventManager->getAllListeners() as $eventName => $listeners) {
foreach ($listeners as $listener) {
if ([] === $eventClassesToDisable || \in_array($listener::class, $eventClassesToDisable, true)) {
$eventManager->removeEventListener([$eventName], $listener);
$removed[$eventName][] = $listener;
}
}
}

return $removed;
}

/**
* Re-adds Doctrine event listeners previously removed by disableDoctrineEvents().
*
* @param array<string, list<object>> $removedListeners
*/
private function restoreDoctrineEvents(ObjectManager $om, array $removedListeners): void
{
if ([] === $removedListeners || !method_exists($om, 'getEventManager')) {
return;
}

/** @var EventManager $eventManager */
$eventManager = $om->getEventManager();

foreach ($removedListeners as $eventName => $listeners) {
foreach ($listeners as $listener) {
$eventManager->addEventListener([$eventName], $listener);
}
}
}

/**
* Temporarily removes Doctrine entity listeners by modifying the entity's ClassMetadata.
*
* @param class-string $entityClass
* @param list<class-string>|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed
*
* @return array<string, list<array{class: class-string, method: string}>> original entityListeners for later restoration
*/
private function disableEntityListeners(ObjectManager $om, string $entityClass, ?array $eventClassesToDisable): array
{
if (null === $eventClassesToDisable || !$om instanceof EntityManagerInterface) {
return [];
}

$metadata = $om->getClassMetadata($entityClass);
$original = $metadata->entityListeners;

if ([] === $original) {
return [];
}

if ([] === $eventClassesToDisable) {
$metadata->entityListeners = [];
} else {
$filtered = [];
foreach ($original as $event => $listeners) {
foreach ($listeners as $listener) {
if (!\in_array($listener['class'], $eventClassesToDisable, true)) {
$filtered[$event][] = $listener;
}
}
}
$metadata->entityListeners = $filtered;
}

return $original;
}

/**
* Restores entity listeners previously removed by disableEntityListeners().
*
* @param class-string $entityClass
* @param array<string, list<array{class: class-string, method: string}>> $original
*/
private function restoreEntityListeners(ObjectManager $om, string $entityClass, array $original): void
{
if ([] === $original || !$om instanceof EntityManagerInterface) {
return;
}

$om->getClassMetadata($entityClass)->entityListeners = $original;
}

/**
* Merges two sets of event classes to disable, following these rules:
* - null + anything = anything (null means "no disabling requested")
* - [] + anything = [] ([] means "disable all", takes precedence)
* - [A] + [B] = [A, B] (union of specific classes)
*
* @param list<class-string>|null $a
* @param list<class-string>|null $b
*
* @return list<class-string>|null
*/
private function mergeEventClasses(?array $a, ?array $b): ?array
{
if (null === $a) {
return $b;
}

if (null === $b) {
return $a;
}

if ([] === $a || [] === $b) {
return [];
}

return \array_values(\array_unique([...$a, ...$b]));
}

/**
* @param class-string $class
*
Expand Down
25 changes: 23 additions & 2 deletions src/Persistence/PersistentObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ abstract class PersistentObjectFactory extends ObjectFactory

private PersistMode $persist = PersistMode::PERSIST;

/** @var list<class-string>|null null = disabled not requested, [] = disable all, [Foo::class] = disable specific */
private ?array $disabledDoctrineEventClasses = null;

/** @phpstan-var array<int, list<callable(T, Parameters, static):void|callable(T, Parameters, static):bool>> */
private array $afterPersist = [];

Expand Down Expand Up @@ -261,7 +264,7 @@ public function create(callable|array $attributes = []): object
throw new \LogicException('Persistence cannot be used in unit tests.');
}

$configuration->persistence()->save($object);
$configuration->persistence()->save($object, $this->disabledDoctrineEventClasses);

return $object;
}
Expand All @@ -282,6 +285,20 @@ final public function withoutPersisting(): static
return $clone;
}

/**
* Disable Doctrine event listeners/subscribers during object creation.
* Call with no arguments to disable all listeners, or pass specific class names to disable selectively.
*
* @param class-string ...$classes
*/
final public function withoutDoctrineEvents(string ...$classes): static
{
$clone = clone $this;
$clone->disabledDoctrineEventClasses = \array_values($classes);

return $clone;
}

final public function withAutorefresh(): static
{
if (\PHP_VERSION_ID < 80400) {
Expand Down Expand Up @@ -378,6 +395,10 @@ protected function normalizeParameter(string $field, mixed $value): mixed
->withPersistMode($this->persist)
->notRootFactory();

if (null !== $this->disabledDoctrineEventClasses) {
$value = $value->withoutDoctrineEvents(...$this->disabledDoctrineEventClasses);
}

$pm = Configuration::instance()->persistence();

$relationshipMetadata = $pm->bidirectionalRelationshipMetadata(static::class(), $value::class(), $field);
Expand Down Expand Up @@ -541,7 +562,7 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact
};
}

Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks);
Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks, $factoryUsed->disabledDoctrineEventClasses);
},
self::PRIORITY_SCHEDULE_FOR_INSERT
)
Expand Down
Loading
Loading