Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: flush only once per calls to Factory::create() in userland #836

Draft
wants to merge 3 commits into
base: 2.3.x
Choose a base branch
from
Draft
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
106 changes: 80 additions & 26 deletions src/Persistence/PersistenceManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ final class PersistenceManager
private bool $flush = true;
private bool $persist = true;

/** @var list<object> */
private array $objectsToPersist = [];

/** @var list<callable():void> */
private array $afterPersistCallbacks = [];

private bool $transactinStarted = false;

/**
* @param iterable<PersistenceStrategy> $strategies
*/
Expand Down Expand Up @@ -75,18 +80,48 @@ public function save(object $object): object
$om->persist($object);
$this->flush($om);

if ($this->afterPersistCallbacks) {
$afterPersistCallbacks = $this->afterPersistCallbacks;
$this->afterPersistCallbacks = [];
return $object;
}

foreach ($afterPersistCallbacks as $afterPersistCallback) {
$afterPersistCallback();
/**
* We're using so-called "transactions" to group multiple persist/flush operations
* This prevents such code to persist the whole batch of objects in the normalization phase:
* ```php
* SomeFactory::createOne(['item' => lazy(fn() => OtherFactory::createOne())]);
* ```
*/
public function startTransaction(): void
{
$this->transactinStarted = true;
}

public function isTransactionStarted(): bool
{
return $this->transactinStarted;
}

public function commit(): void
{
$objectManagers = [];

$objectsToPersist = $this->objectsToPersist;
$this->objectsToPersist = [];
$this->transactinStarted = false;

foreach ($objectsToPersist as $object) {
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
$om->persist($object);

if (!\in_array($om, $objectManagers, true)) {
$objectManagers[] = $om;
}
}

$this->save($object);
foreach ($objectManagers as $om) {
$this->flush($om);
}

return $object;
$this->callPostPersistCallbacks();
}

/**
Expand All @@ -103,23 +138,19 @@ public function scheduleForInsert(object $object, array $afterPersistCallbacks =
$object = unproxy($object);
}

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

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

return $object;
}
// if (0 === \count($this->objectsToPersist)) {
// throw new \LogicException('No transaction started yet.');
// }

public function forget(object $object): void
{
if ($this->isPersisted($object)) {
throw new \LogicException('Cannot forget an object already persisted.');
}
// $transactionCount = \count($this->objectsToPersist) - 1;
$this->objectsToPersist[] = $object;

$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
$this->afterPersistCallbacks = [
...$this->afterPersistCallbacks,
...$afterPersistCallbacks,
];

$om->detach($object);
return $object;
}

/**
Expand All @@ -137,11 +168,9 @@ public function flushAfter(callable $callback): mixed

$this->flush = true;

foreach ($this->strategies as $strategy) {
foreach ($strategy->objectManagers() as $om) {
$this->flush($om);
}
}
$this->flushAllStrategies();

$this->callPostPersistCallbacks();

return $result;
}
Expand Down Expand Up @@ -372,6 +401,31 @@ public static function isOrmOnly(): bool
})();
}

private function flushAllStrategies(): void
{
foreach ($this->strategies as $strategy) {
foreach ($strategy->objectManagers() as $om) {
$this->flush($om);
}
}
}

private function callPostPersistCallbacks(): void
{
if (!$this->flush || [] === $this->afterPersistCallbacks) {
return;
}

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

foreach ($afterPersistCallbacks as $afterPersistCallback) {
$afterPersistCallback();
}

$this->flushAllStrategies();
}

/**
* @param class-string $class
*
Expand Down
50 changes: 35 additions & 15 deletions src/Persistence/PersistentObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ abstract class PersistentObjectFactory extends ObjectFactory
/** @var list<callable(T):void> */
private array $tempAfterInstantiate = [];

private bool $isRootFactory = true;

/**
* @phpstan-param mixed|Parameters $criteriaOrId
*
Expand Down Expand Up @@ -196,6 +198,13 @@ final public static function truncate(): void
*/
public function create(callable|array $attributes = []): object
{
$transactionStarted = false;

if (Configuration::isBooted() && PersistMode::PERSIST === $this->persistMode() && $this->isRootFactory) {
$transactionStarted = Configuration::instance()->persistence()->isTransactionStarted();
Configuration::instance()->persistence()->startTransaction();
}

$object = parent::create($attributes);

foreach ($this->tempAfterInstantiate as $callback) {
Expand All @@ -206,7 +215,7 @@ public function create(callable|array $attributes = []): object

$this->throwIfCannotCreateObject();

if (PersistMode::PERSIST !== $this->persistMode()) {
if ($transactionStarted || PersistMode::PERSIST !== $this->persistMode() || !$this->isRootFactory) {
return $object;
}

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

$configuration->persistence()->save($object);
$configuration->persistence()->commit();

return $object;
}
Expand Down Expand Up @@ -296,24 +305,25 @@ protected function normalizeParameter(string $field, mixed $value): mixed
if ($inversedRelationshipMetadata && !$inversedRelationshipMetadata->isCollection) {
$inverseField = $inversedRelationshipMetadata->inverseField;

$inversedObject = $value->withPersistMode(
$this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING
)
// we need to handle the circular dependency involved by inversed one-to-one relationship:
// a placeholder object is used, which will be replaced by the real object, after its instantiation
$inverseObjectPlaceholder = (new \ReflectionClass($value::class()))->newInstanceWithoutConstructor();

// we need to handle the circular dependency involved by inversed one-to-one relationship:
// a placeholder object is used, which will be replaced by the real object, after its instantiation
->create([
$inverseField => $placeholder = (new \ReflectionClass(static::class()))->newInstanceWithoutConstructor(),
]);
$this->tempAfterInstantiate[] = function(object $object) use ($value, $inverseField, $field) {
$inverseObject = $value->withPersistMode(
$this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING
)
->notRootFactory()
->create([$inverseField => $object]);

$inversedObject = unproxy($inversedObject, withAutoRefresh: false);
$inverseObject = unproxy($inverseObject, withAutoRefresh: false);

$this->tempAfterInstantiate[] = static function(object $object) use ($inversedObject, $inverseField, $pm, $placeholder) {
$pm->forget($placeholder);
set($inversedObject, $inverseField, $object);
set($object, $field, $inverseObject);
};

return $inversedObject;
return $inverseObjectPlaceholder;
} else {
$value = $value->notRootFactory();
}
}

Expand Down Expand Up @@ -384,6 +394,8 @@ protected function normalizeObject(object $object): object

if (!$persistenceManager->isPersisted($object)) {
$persistenceManager->scheduleForInsert($object);

return $object;
}

try {
Expand Down Expand Up @@ -450,4 +462,12 @@ private function throwIfCannotCreateObject(): void

throw new \LogicException(\sprintf('Cannot create object in a data provider for non-proxy factories. Transform your factory into a "%s", or call "create()" method in the test. See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#phpunit-data-providers', PersistentProxyObjectFactory::class));
}

private function notRootFactory(): static
{
$clone = clone $this;
$clone->isRootFactory = false;

return $clone;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne;

use Doctrine\ORM\Mapping as ORM;
use Zenstruck\Foundry\Tests\Fixture\Model\Base;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[ORM\Entity]
#[ORM\Table('inversed_one_to_one_with_many_to_one_inverse_side')]
class InverseSide extends Base
{
#[ORM\OneToOne(mappedBy: 'inverseSide')]
public ?OwningSide $owningSide = null;

#[ORM\ManyToOne()]
public ?Item $item = null;

public function __construct(
#[ORM\Column()]
public string $mandatoryField,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne;

use Doctrine\ORM\Mapping as ORM;
use Zenstruck\Foundry\Tests\Fixture\Model\Base;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[ORM\Entity]
#[ORM\Table('inversed_one_to_one_with_many_to_one_item_if_collection')]
class Item extends Base
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne;

use Doctrine\ORM\Mapping as ORM;
use Zenstruck\Foundry\Tests\Fixture\Model\Base;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[ORM\Entity]
#[ORM\Table('inversed_one_to_one_with_many_to_one_owning_side')]
class OwningSide extends Base
{
#[ORM\OneToOne(inversedBy: 'owningSide')]
public ?InverseSide $inverseSide = null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[ORM\Entity]
#[ORM\Table('inversed_one_to_one_with_custom_id_inverse')]
class InverseSide
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
public Uuid $id;

public function __construct(
#[ORM\OneToOne(mappedBy: 'inverseSide', cascade: ['persist'])] // @phpstan-ignore doctrine.associationType
public OwningSide $owningSide,
) {
$this->id = Uuid::v7();
}
}
Loading
Loading