Skip to content

Commit a4d38a8

Browse files
committed
fix: only call om::persist() once all objects are created
1 parent 28715d1 commit a4d38a8

File tree

8 files changed

+248
-51
lines changed

8 files changed

+248
-51
lines changed

src/Persistence/PersistenceManager.php

+80-31
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ final class PersistenceManager
3131
private bool $flush = true;
3232
private bool $persist = true;
3333

34-
/** @var list<callable():void> */
34+
/** @var array<int, list<object>> */
35+
private array $objectsToPersist = [];
36+
37+
/** @var array<int, list<callable():void>> */
3538
private array $afterPersistCallbacks = [];
3639

3740
/**
@@ -78,6 +81,45 @@ public function save(object $object): object
7881
return $object;
7982
}
8083

84+
/**
85+
* We're using so-called "transactions" to group multiple persist/flush operations
86+
* This prevents such code to persist the whole batch of objects in the normalization phase:
87+
* ```php
88+
* SomeFactory::createOne(['item' => lazy(fn() => OtherFactory::createOne())]);
89+
* ```.
90+
*/
91+
public function startTransaction(): void
92+
{
93+
$this->objectsToPersist[] = [];
94+
$this->afterPersistCallbacks[] = [];
95+
}
96+
97+
public function commit(): void
98+
{
99+
$objectManagers = [];
100+
101+
$objectsToPersist = \array_pop($this->objectsToPersist);
102+
103+
if (null === $objectsToPersist) {
104+
return;
105+
}
106+
107+
foreach ($objectsToPersist as $object) {
108+
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
109+
$om->persist($object);
110+
111+
if (!\in_array($om, $objectManagers, true)) {
112+
$objectManagers[] = $om;
113+
}
114+
}
115+
116+
foreach ($objectManagers as $om) {
117+
$this->flush($om);
118+
}
119+
120+
$this->callPostPersistCallbacks();
121+
}
122+
81123
/**
82124
* @template T of object
83125
*
@@ -92,23 +134,19 @@ public function scheduleForInsert(object $object, array $afterPersistCallbacks =
92134
$object = unproxy($object);
93135
}
94136

95-
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
96-
$om->persist($object);
97-
98-
$this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks];
99-
100-
return $object;
101-
}
102-
103-
public function forget(object $object): void
104-
{
105-
if ($this->isPersisted($object)) {
106-
throw new \LogicException('Cannot forget an object already persisted.');
137+
if (0 === \count($this->objectsToPersist)) {
138+
throw new \LogicException('No transaction started yet.');
107139
}
108140

109-
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
141+
$transactionCount = \count($this->objectsToPersist) - 1;
142+
$this->objectsToPersist[$transactionCount][] = $object;
143+
144+
$this->afterPersistCallbacks[$transactionCount] = [
145+
...$this->afterPersistCallbacks[$transactionCount],
146+
...$afterPersistCallbacks,
147+
];
110148

111-
$om->detach($object);
149+
return $object;
112150
}
113151

114152
/**
@@ -126,11 +164,9 @@ public function flushAfter(callable $callback): mixed
126164

127165
$this->flush = true;
128166

129-
foreach ($this->strategies as $strategy) {
130-
foreach ($strategy->objectManagers() as $om) {
131-
$this->flush($om);
132-
}
133-
}
167+
$this->flushAllStrategies();
168+
169+
$this->callPostPersistCallbacks();
134170

135171
return $result;
136172
}
@@ -139,17 +175,6 @@ public function flush(ObjectManager $om): void
139175
{
140176
if ($this->flush) {
141177
$om->flush();
142-
143-
if ($this->afterPersistCallbacks) {
144-
$afterPersistCallbacks = $this->afterPersistCallbacks;
145-
$this->afterPersistCallbacks = [];
146-
147-
foreach ($afterPersistCallbacks as $afterPersistCallback) {
148-
$afterPersistCallback();
149-
}
150-
151-
$this->flush($om);
152-
}
153178
}
154179
}
155180

@@ -372,6 +397,30 @@ public static function isOrmOnly(): bool
372397
})();
373398
}
374399

400+
private function flushAllStrategies(): void
401+
{
402+
foreach ($this->strategies as $strategy) {
403+
foreach ($strategy->objectManagers() as $om) {
404+
$this->flush($om);
405+
}
406+
}
407+
}
408+
409+
private function callPostPersistCallbacks(): void
410+
{
411+
if (!$this->flush || [] === $this->afterPersistCallbacks) {
412+
return;
413+
}
414+
415+
$afterPersistCallbacks = \array_pop($this->afterPersistCallbacks);
416+
417+
foreach ($afterPersistCallbacks as $afterPersistCallback) {
418+
$afterPersistCallback();
419+
}
420+
421+
$this->flushAllStrategies();
422+
}
423+
375424
/**
376425
* @param class-string $class
377426
*

src/Persistence/PersistentObjectFactory.php

+19-15
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ final public static function truncate(): void
198198
*/
199199
public function create(callable|array $attributes = []): object
200200
{
201+
if (PersistMode::PERSIST === $this->persistMode() && $this->isRootFactory) {
202+
Configuration::instance()->persistence()->startTransaction();
203+
}
204+
201205
$object = parent::create($attributes);
202206

203207
foreach ($this->tempAfterInstantiate as $callback) {
@@ -218,7 +222,7 @@ public function create(callable|array $attributes = []): object
218222
throw new \LogicException('Persistence cannot be used in unit tests.');
219223
}
220224

221-
$configuration->persistence()->save($object);
225+
$configuration->persistence()->commit();
222226

223227
return $object;
224228
}
@@ -298,25 +302,23 @@ protected function normalizeParameter(string $field, mixed $value): mixed
298302
if ($inversedRelationshipMetadata && !$inversedRelationshipMetadata->isCollection) {
299303
$inverseField = $inversedRelationshipMetadata->inverseField;
300304

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

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

312-
$inversedObject = unproxy($inversedObject, withAutoRefresh: false);
316+
$inverseObject = unproxy($inverseObject, withAutoRefresh: false);
313317

314-
$this->tempAfterInstantiate[] = static function(object $object) use ($inversedObject, $inverseField, $pm, $placeholder) {
315-
$pm->forget($placeholder);
316-
set($inversedObject, $inverseField, $object);
318+
set($object, $field, $inverseObject);
317319
};
318320

319-
return $inversedObject;
321+
return $inverseObjectPlaceholder;
320322
} else {
321323
$value = $value->notRootFactory();
322324
}
@@ -389,6 +391,8 @@ protected function normalizeObject(object $object): object
389391

390392
if (!$persistenceManager->isPersisted($object)) {
391393
$persistenceManager->scheduleForInsert($object);
394+
395+
return $object;
392396
}
393397

394398
try {

tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/InverseSide.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class InverseSide extends Base
3131

3232
public function __construct(
3333
#[ORM\Column()]
34-
public string $mandatoryField
34+
public string $mandatoryField,
3535
) {
3636
}
3737
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the zenstruck/foundry package.
7+
*
8+
* (c) Kevin Bond <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId;
15+
16+
use Doctrine\ORM\Mapping as ORM;
17+
use Symfony\Component\Uid\Uuid;
18+
19+
/**
20+
* @author Nicolas PHILIPPE <[email protected]>
21+
*/
22+
#[ORM\Entity]
23+
#[ORM\Table('inversed_one_to_one_with_custom_id_inverse')]
24+
class InverseSide
25+
{
26+
#[ORM\Id]
27+
#[ORM\Column(type: 'uuid')]
28+
public Uuid $id;
29+
30+
public function __construct(
31+
#[ORM\OneToOne(mappedBy: 'inverseSide', cascade: ['persist'])] // @phpstan-ignore doctrine.associationType
32+
public OwningSide $owningSide,
33+
) {
34+
$this->id = Uuid::v7();
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the zenstruck/foundry package.
7+
*
8+
* (c) Kevin Bond <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId;
15+
16+
use Doctrine\ORM\Mapping as ORM;
17+
use Symfony\Component\Uid\Uuid;
18+
19+
/**
20+
* @author Nicolas PHILIPPE <[email protected]>
21+
*/
22+
#[ORM\Entity]
23+
#[ORM\Table('inversed_one_to_one_with_custom_id_owning')]
24+
class OwningSide
25+
{
26+
#[ORM\Id]
27+
#[ORM\Column(type: 'uuid')]
28+
public Uuid $id;
29+
30+
#[ORM\OneToOne(inversedBy: 'owningSide', cascade: ['persist'])]
31+
public ?InverseSide $inverseSide = null;
32+
33+
public function __construct()
34+
{
35+
$this->id = Uuid::v7();
36+
}
37+
}

tests/Integration/ORM/EdgeCasesRelationshipTest.php

+21-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne;
2727
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithNonNullableOwning;
2828
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithOneToMany;
29+
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId;
2930
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithSetter;
3031
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\ManyToOneToSelfReferencing;
3132
use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\OneToManyWithUnionType;
@@ -102,7 +103,7 @@ public function inverse_one_to_one_with_both_nullable(): void
102103
#[DataProvider('provideCascadeRelationshipsCombinations')]
103104
#[UsingRelationships(InversedOneToOneWithOneToMany\OwningSide::class, ['inverseSide'])]
104105
#[UsingRelationships(InversedOneToOneWithOneToMany\Item::class, ['owningSide'])]
105-
#[RequiresPhpunit('^11.4')]
106+
#[RequiresPhpunit('>=11.4')]
106107
public function inverse_one_to_one_with_one_to_many(): void
107108
{
108109
$inverseSideFactory = persistent_factory(InversedOneToOneWithOneToMany\InverseSide::class);
@@ -143,7 +144,7 @@ public function many_to_many_to_self_referencing_inverse_side(): void
143144
#[Test]
144145
#[DataProvider('provideCascadeRelationshipsCombinations')]
145146
#[UsingRelationships(IndexedOneToMany\ParentEntity::class, ['items'])]
146-
#[RequiresPhpunit('^11.4')]
147+
#[RequiresPhpunit('>=11.4')]
147148
public function indexed_one_to_many(): void
148149
{
149150
$parentFactory = persistent_factory(IndexedOneToMany\ParentEntity::class);
@@ -189,6 +190,24 @@ public function inversed_one_to_one_can_be_used_after_other_relationship(): void
189190
self::assertNotNull($inverseSide->item);
190191
}
191192

193+
/** @test */
194+
#[Test]
195+
#[DataProvider('provideCascadeRelationshipsCombinations')]
196+
#[UsingRelationships(InversedOneToOneWithoutAutoGeneratedId\OwningSide::class, ['inverseSide'])]
197+
#[RequiresPhpunit('>=11.4')]
198+
public function inverse_one_to_one_with_custom_id(): void
199+
{
200+
$owningSideFactory = persistent_factory(InversedOneToOneWithoutAutoGeneratedId\OwningSide::class);
201+
$inverseSideFactory = persistent_factory(InversedOneToOneWithoutAutoGeneratedId\InverseSide::class);
202+
203+
$inverseSide = $inverseSideFactory->create(['owningSide' => $owningSideFactory]);
204+
205+
$owningSideFactory::assert()->count(1);
206+
$inverseSideFactory::assert()->count(1);
207+
208+
self::assertSame($inverseSide, $inverseSide->owningSide->inverseSide);
209+
}
210+
192211
/** @test */
193212
#[Test]
194213
public function object_with_union_type(): void

0 commit comments

Comments
 (0)