Skip to content

Commit aa6f6ee

Browse files
committed
Fix CwaFixtureBuilder: service registration, explicit persists, idempotent flush
- Register CwaFixtureBuilder as a bundle service (was requiring manual wiring) - Add persistWithAssociations() to walk owning-side associations and persist every related entity explicitly — no Doctrine cascade relied upon - Add public persist() method for app code to register app-specific entities (e.g. HtmlContent set on a BlogArticleData) before flush - Call persistTimestampedFields() on ComponentGroup and ComponentPosition (were missing, causing stale timestamps) - Phases 1–3 now run exactly once; phase 4 runs on every flush() call so positions added after the first flush (e.g. nav links after routes exist) are picked up correctly without re-running route generation - GroupBuilder.getNewComponents()/getNewPageDataPositions() advance a cursor so only unprocessed items are created on each phase-4 run - phaseThree() skips entities that already have a route (safe for re-run) - Add 3 new tests: public persist(), two-flush positions, single route-gen call
1 parent d98d817 commit aa6f6ee

3 files changed

Lines changed: 179 additions & 14 deletions

File tree

src/Fixture/Builder/GroupBuilder.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class GroupBuilder
1818
private array $components = [];
1919
private array $pageDataPositions = [];
2020
private int $nextSort = 10;
21+
private int $processedComponentCount = 0;
22+
private int $processedPositionCount = 0;
2123

2224
public function __construct(
2325
private readonly string $name,
@@ -60,4 +62,22 @@ public function getPageDataPositions(): array
6062
{
6163
return $this->pageDataPositions;
6264
}
65+
66+
/** Returns only components added since the last call — safe to call on every flush(). */
67+
public function getNewComponents(): array
68+
{
69+
$new = \array_slice($this->components, $this->processedComponentCount);
70+
$this->processedComponentCount = \count($this->components);
71+
72+
return $new;
73+
}
74+
75+
/** Returns only pageData positions added since the last call — safe to call on every flush(). */
76+
public function getNewPageDataPositions(): array
77+
{
78+
$new = \array_slice($this->pageDataPositions, $this->processedPositionCount);
79+
$this->processedPositionCount = \count($this->pageDataPositions);
80+
81+
return $new;
82+
}
6383
}

src/Fixture/CwaFixtureBuilder.php

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class CwaFixtureBuilder
6363
/** Maps spl_object_id(GroupBuilder) → ComponentGroup for use in phase 4 */
6464
private array $componentGroupMap = [];
6565

66+
/** Tracks object IDs already passed to persist() to avoid cycles in persistWithAssociations() */
67+
private array $persistedEntities = [];
68+
69+
/** Phases 1–3 run exactly once; phase 4 runs on every flush() call to pick up new positions */
70+
private bool $initialFlushDone = false;
71+
6672
public function __construct(
6773
private readonly TimestampedDataPersister $timestampedPersister,
6874
private readonly RouteGeneratorInterface $routeGenerator,
@@ -157,9 +163,14 @@ public function pageData(
157163
return $builder;
158164
}
159165

166+
/**
167+
* Explicitly persist an entity and walk its owning-side associations to persist related objects.
168+
* Use this for app-specific entities that the builder doesn't manage (e.g. HtmlContent set on a PageData).
169+
* Does not rely on Doctrine cascade — every related object is persisted explicitly.
170+
*/
160171
public function persist(object $entity): static
161172
{
162-
$this->manager->persist($entity);
173+
$this->persistWithAssociations($entity);
163174

164175
return $this;
165176
}
@@ -173,12 +184,20 @@ public function getRoute(string $routeName): Route
173184
return $this->namedRoutes[$routeName];
174185
}
175186

187+
/**
188+
* Phases 1–3 run exactly once (on first call).
189+
* Phase 4 runs every call to pick up positions added after the first flush (e.g. nav links added after routes exist).
190+
*/
176191
public function flush(): void
177192
{
178-
$this->phaseOne();
179-
$this->evaluateNested();
180-
$this->phaseTwo();
181-
$this->phaseThree();
193+
if (!$this->initialFlushDone) {
194+
$this->phaseOne();
195+
$this->evaluateNested();
196+
$this->phaseTwo();
197+
$this->phaseThree();
198+
$this->initialFlushDone = true;
199+
}
200+
182201
$this->phaseFour();
183202
}
184203

@@ -211,8 +230,9 @@ private function evaluateNested(): void
211230

212231
$newPageDataSpecs = \array_slice($this->pageDataSpecs, $existingPageDataCount);
213232
foreach ($newPageDataSpecs as $spec) {
214-
$this->timestampedPersister->persistTimestampedFields($spec['builder']->getPageData(), true);
215-
$this->manager->persist($spec['builder']->getPageData());
233+
$pageData = $spec['builder']->getPageData();
234+
$this->timestampedPersister->persistTimestampedFields($pageData, true);
235+
$this->persistWithAssociations($pageData);
216236
$hasNew = true;
217237
}
218238

@@ -224,7 +244,7 @@ private function evaluateNested(): void
224244
$page->layout = $layoutBuilder->getLayout();
225245
}
226246
$this->timestampedPersister->persistTimestampedFields($page, true);
227-
$this->manager->persist($page);
247+
$this->persistWithAssociations($page);
228248
$hasNew = true;
229249
}
230250

@@ -238,7 +258,7 @@ private function phaseOne(): void
238258
foreach ($this->layoutBuilders as $layoutBuilder) {
239259
$layout = $layoutBuilder->getLayout();
240260
$this->timestampedPersister->persistTimestampedFields($layout, true);
241-
$this->manager->persist($layout);
261+
$this->persistWithAssociations($layout);
242262
}
243263

244264
foreach ($this->pageSpecs as $spec) {
@@ -248,7 +268,7 @@ private function phaseOne(): void
248268
$page->layout = $layoutBuilder->getLayout();
249269
}
250270
$this->timestampedPersister->persistTimestampedFields($page, true);
251-
$this->manager->persist($page);
271+
$this->persistWithAssociations($page);
252272
}
253273

254274
foreach ($this->pageDataSpecs as $spec) {
@@ -257,7 +277,7 @@ private function phaseOne(): void
257277
$pageData->page = $this->pageSpecs[$spec['templateRef']]['builder']->getPage();
258278
}
259279
$this->timestampedPersister->persistTimestampedFields($pageData, true);
260-
$this->manager->persist($pageData);
280+
$this->persistWithAssociations($pageData);
261281
}
262282

263283
$this->manager->flush();
@@ -313,6 +333,9 @@ private function phaseThree(): void
313333
foreach ($this->orderedRouteSpecs as $spec) {
314334
if ('page' === $spec['type']) {
315335
$page = $spec['builder']->getPage();
336+
if (null !== $page->getRoute()) {
337+
continue;
338+
}
316339
if (null !== $spec['route']) {
317340
$route = $this->createExplicitRoute($spec['route'], $spec['routeName']);
318341
$route->setPage($page);
@@ -330,6 +353,9 @@ private function phaseThree(): void
330353
}
331354
} else {
332355
$pageData = $spec['builder']->getPageData();
356+
if (null !== $pageData->getRoute()) {
357+
continue;
358+
}
333359
if (null !== $spec['route']) {
334360
$route = $this->createExplicitRoute($spec['route'], $spec['routeName']);
335361
$route->setPageData($pageData);
@@ -385,19 +411,19 @@ private function createPositions(GroupBuilder $groupBuilder): bool
385411

386412
$hasAny = false;
387413

388-
foreach ($groupBuilder->getComponents() as $item) {
414+
foreach ($groupBuilder->getNewComponents() as $item) {
389415
$component = $item['component'];
390416
$position = new ComponentPosition();
391417
$position->sortValue = $item['sort'];
392418
$position->component = $component;
393419
$componentGroup->addComponentPosition($position);
394420
$this->timestampedPersister->persistTimestampedFields($position, true);
395-
$this->manager->persist($component);
421+
$this->persistWithAssociations($component);
396422
$this->manager->persist($position);
397423
$hasAny = true;
398424
}
399425

400-
foreach ($groupBuilder->getPageDataPositions() as $item) {
426+
foreach ($groupBuilder->getNewPageDataPositions() as $item) {
401427
$position = new ComponentPosition();
402428
$position->sortValue = $item['sort'];
403429
$position->pageDataProperty = $item['property'];
@@ -425,4 +451,57 @@ private function deriveRouteName(string $path): string
425451

426452
return $slug ?: 'root';
427453
}
454+
455+
/**
456+
* Persists an entity and recursively persists all owning-side associated objects.
457+
* Does not rely on Doctrine cascade — every object is persisted explicitly.
458+
* Uses spl_object_id tracking to prevent cycles.
459+
*/
460+
private function persistWithAssociations(object $entity): void
461+
{
462+
$oid = spl_object_id($entity);
463+
if (isset($this->persistedEntities[$oid])) {
464+
return;
465+
}
466+
$this->persistedEntities[$oid] = true;
467+
$this->manager->persist($entity);
468+
469+
try {
470+
$metadata = $this->manager->getClassMetadata($entity::class);
471+
foreach ($metadata->getAssociationNames() as $assocName) {
472+
if ($metadata->isAssociationInverseSide($assocName)) {
473+
continue;
474+
}
475+
$related = $this->readProperty($entity, $assocName);
476+
if (null === $related) {
477+
continue;
478+
}
479+
if (is_iterable($related)) {
480+
foreach ($related as $item) {
481+
if (is_object($item)) {
482+
$this->persistWithAssociations($item);
483+
}
484+
}
485+
} else {
486+
$this->persistWithAssociations($related);
487+
}
488+
}
489+
} catch (\Exception) {
490+
// Entity class not in Doctrine metadata (e.g. during unit tests with stubs)
491+
}
492+
}
493+
494+
private function readProperty(object $entity, string $property): mixed
495+
{
496+
$class = new \ReflectionClass($entity);
497+
do {
498+
if ($class->hasProperty($property)) {
499+
$prop = $class->getProperty($property);
500+
501+
return $prop->isInitialized($entity) ? $prop->getValue($entity) : null;
502+
}
503+
} while ($class = $class->getParentClass());
504+
505+
return null;
506+
}
428507
}

tests/Fixture/CwaFixtureBuilderTest.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Doctrine\Persistence\ObjectManager;
1616
use PHPUnit\Framework\TestCase;
1717
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
18+
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPage;
1819
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData;
1920
use Silverback\ApiComponentsBundle\Entity\Core\ComponentGroup;
2021
use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition;
@@ -400,6 +401,71 @@ public function test_parent_route_is_created_before_child_route(): void
400401
$this->assertSame(spl_object_id($parentPageData), $createOrder[0]);
401402
}
402403

404+
// --- Association graph auto-persist ---
405+
406+
public function test_public_persist_persists_entity_via_manager(): void
407+
{
408+
$persisted = [];
409+
$em = $this->collectingEm($persisted);
410+
411+
$component = new class extends AbstractComponent {};
412+
413+
$builder = $this->makeBuilder($em, $this->autoRouteGenerator());
414+
$builder->persist($component);
415+
$builder->flush();
416+
417+
$this->assertContains($component, $persisted);
418+
}
419+
420+
public function test_flush_second_call_only_processes_new_positions(): void
421+
{
422+
$persisted = [];
423+
$em = $this->collectingEm($persisted);
424+
425+
$component1 = new class extends AbstractComponent {};
426+
$component2 = new class extends AbstractComponent {};
427+
428+
$routeGenerator = $this->autoRouteGenerator();
429+
$builder = $this->makeBuilder($em, $routeGenerator);
430+
$builder->layout('main', 'CwaLayoutPrimary');
431+
$navGroup = $builder->layout('main', 'CwaLayoutPrimary')->group('nav');
432+
$builder->page('home', 'Template', layout: 'main', route: '/');
433+
434+
$navGroup->add($component1);
435+
$builder->flush(); // first flush — component1 processed
436+
437+
$navGroup->add($component2);
438+
$builder->flush(); // second flush — only component2 should be processed
439+
440+
$positions = array_values(array_filter($persisted, static fn ($e) => $e instanceof ComponentPosition));
441+
// component1 → position1 in flush 1; component2 → position2 in flush 2
442+
$this->assertCount(2, $positions);
443+
$components = array_map(static fn ($p) => $p->component, $positions);
444+
$this->assertContains($component1, $components);
445+
$this->assertContains($component2, $components);
446+
}
447+
448+
public function test_phases_one_to_three_run_only_once_across_multiple_flushes(): void
449+
{
450+
$routeGenerator = $this->createMock(RouteGeneratorInterface::class);
451+
$routeGenerator->expects($this->once()) // must be called exactly once despite two flush() calls
452+
->method('create')
453+
->willReturnCallback(static function (object $entity): Route {
454+
$route = new Route();
455+
$route->setPath('/' . spl_object_id($entity));
456+
$route->setName((string) spl_object_id($entity));
457+
$entity->setRoute($route);
458+
return $route;
459+
});
460+
461+
$builder = $this->makeBuilder(routeGenerator: $routeGenerator);
462+
$builder->layout('main', 'CwaLayoutPrimary');
463+
$builder->page('home', 'Template', layout: 'main'); // will call routeGenerator->create() once
464+
465+
$builder->flush();
466+
$builder->flush(); // second flush must NOT call routeGenerator->create() again
467+
}
468+
403469
public function test_parent_pagedata_route_created_before_child_pagedata_route(): void
404470
{
405471
$createOrder = [];

0 commit comments

Comments
 (0)