Skip to content

Commit f856301

Browse files
authored
Add RefersToMorphed relation for cyclic morphed references (#570)
* Add RefersToMorphed relation for cyclic morphed references BelongsToMorphed inherits the hard "parent before child" dependency of BelongsTo and therefore deadlocks the pool when persisting a closed cycle (A > A or A > B > A) in a single transaction. There was no morphed counterpart of RefersTo to break such cycles. Add Relation::REFERS_TO_MORPHED and the RefersToMorphed relation, which mirrors BelongsToMorphed on top of RefersTo: it stores the outer key and the target role (morph key) on the owner but resolves the outer key in a deferred, "soft" way, allowing self-linked and cyclic morphed references to be persisted within one transaction. The morph key is kept in sync with the related role and cleared when the relation is set to null; the BelongsToMorphedLoader is reused. Tests cover A > A and A > B > A (read, single-transaction create, detach) on all four drivers, plus the previously uncovered interaction of both morphed belongs-to/refers-to relations with Options::$ignoreUninitializedRelations. * Add lazy-loading assertions for RefersToMorphed cycles Verify the promise reference resolves with the expected number of read queries: a self-reference is taken from the heap (0 queries), a parent that is not yet loaded costs exactly one query, and the back-reference across an A > B > A cycle is served from the heap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add eager-loading and BulkLoader tests for RefersToMorphed Cover one-level eager loading via Select::load() and batch loading via BulkLoader for the morphed refers-to relation (reusing BelongsToMorphedLoader). Both resolve the parent up front, so accessing it afterwards issues no queries.
1 parent 8109639 commit f856301

13 files changed

Lines changed: 630 additions & 3 deletions

File tree

psalm-baseline.xml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,10 +1653,22 @@
16531653
<code><![CDATA[parent::getReferenceScope($node)]]></code>
16541654
</PossiblyNullOperand>
16551655
</file>
1656-
<file src="src/Relation/RefersTo.php">
1656+
<file src="src/Relation/Morphed/RefersToMorphed.php">
16571657
<ClassMustBeFinal>
1658-
<code><![CDATA[RefersTo]]></code>
1658+
<code><![CDATA[RefersToMorphed]]></code>
16591659
</ClassMustBeFinal>
1660+
<MixedArgument>
1661+
<code><![CDATA[$related]]></code>
1662+
<code><![CDATA[$target]]></code>
1663+
<code><![CDATA[$target]]></code>
1664+
</MixedArgument>
1665+
<MixedAssignment>
1666+
<code><![CDATA[$related]]></code>
1667+
<code><![CDATA[$target]]></code>
1668+
<code><![CDATA[$this->morphKey]]></code>
1669+
</MixedAssignment>
1670+
</file>
1671+
<file src="src/Relation/RefersTo.php">
16601672
<MixedArgument>
16611673
<code><![CDATA[$related]]></code>
16621674
<code><![CDATA[$related]]></code>
@@ -2217,14 +2229,22 @@
22172229
<MixedArgument>
22182230
<code><![CDATA[$row]]></code>
22192231
<code><![CDATA[$this->define(SchemaInterface::COLUMNS)]]></code>
2220-
<code><![CDATA[$this->factory->make($loader->options['scope'])]]></code>
22212232
<code><![CDATA[$this->options['minify']]]></code>
22222233
<code><![CDATA[$this->options['minify']]]></code>
22232234
<code><![CDATA[$this->schema[$key]]]></code>
22242235
<code><![CDATA[$this->schema[$key]]]></code>
2236+
<code><![CDATA[match (true) {
2237+
$scope instanceof ScopeInterface => $scope,
2238+
\is_string($scope) => $this->factory->make($scope),
2239+
// false/null explicitly disable the scope for this relation
2240+
$scope === false, $scope === null => null,
2241+
// true (the default) means: use the relation source's scope
2242+
default => $this->source->getScope(),
2243+
}]]></code>
22252244
</MixedArgument>
22262245
<MixedAssignment>
22272246
<code><![CDATA[$row]]></code>
2247+
<code><![CDATA[$scope]]></code>
22282248
</MixedAssignment>
22292249
<MixedReturnStatement>
22302250
<code><![CDATA[$this->options['as']]]></code>

src/Config/RelationConfig.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public static function getDefault(): self
5757
self::LOADER => Select\Loader\Morphed\BelongsToMorphedLoader::class,
5858
self::RELATION => Relation\Morphed\BelongsToMorphed::class,
5959
],
60+
Relation::REFERS_TO_MORPHED => [
61+
self::LOADER => Select\Loader\Morphed\BelongsToMorphedLoader::class,
62+
self::RELATION => Relation\Morphed\RefersToMorphed::class,
63+
],
6064
]);
6165
}
6266

src/Relation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ final class Relation
2929
// Morphed relations
3030
public const BELONGS_TO_MORPHED = 20;
3131
public const MORPHED_HAS_ONE = 21;
32+
public const REFERS_TO_MORPHED = 22;
3233
public const MORPHED_HAS_MANY = 23;
3334

3435
// Custom morph key
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\ORM\Relation\Morphed;
6+
7+
use Cycle\ORM\Exception\RelationException;
8+
use Cycle\ORM\Heap\Node;
9+
use Cycle\ORM\ORMInterface;
10+
use Cycle\ORM\Reference\EmptyReference;
11+
use Cycle\ORM\Reference\Reference;
12+
use Cycle\ORM\Reference\ReferenceInterface;
13+
use Cycle\ORM\Relation;
14+
use Cycle\ORM\Relation\RefersTo;
15+
use Cycle\ORM\Transaction\Pool;
16+
use Cycle\ORM\Transaction\Tuple;
17+
18+
/**
19+
* Morphed variation of the {@see RefersTo} relation. Like {@see BelongsToMorphed} it stores
20+
* both the outer key and the target role (morph key) on the owner, but inherits the deferred,
21+
* "soft" dependency resolution of {@see RefersTo}. This allows the relation to be self linked
22+
* and to participate in cyclic references (A > A, A > B > A) that {@see BelongsToMorphed} can
23+
* not persist in a single transaction.
24+
*
25+
* @internal
26+
*/
27+
class RefersToMorphed extends RefersTo
28+
{
29+
private string $morphKey;
30+
31+
public function __construct(ORMInterface $orm, string $role, string $name, string $target, array $schema)
32+
{
33+
parent::__construct($orm, $role, $name, $target, $schema);
34+
$this->morphKey = $schema[Relation::MORPH_KEY];
35+
}
36+
37+
public function initReference(Node $node): ReferenceInterface
38+
{
39+
$scope = $this->getReferenceScope($node);
40+
$nodeData = $node->getData();
41+
if (!isset($nodeData[$this->morphKey], $scope)) {
42+
return new EmptyReference('?', null);
43+
}
44+
$target = $nodeData[$this->morphKey];
45+
46+
return $scope === [] ? new EmptyReference($target, null) : new Reference($target, $scope);
47+
}
48+
49+
public function prepare(Pool $pool, Tuple $tuple, mixed $related, bool $load = true): void
50+
{
51+
// The parent RefersTo resets the node relation while handling a null value, so capture
52+
// whether there was a related entity before delegating.
53+
$hadRelation = $tuple->node->getRelation($this->getName()) !== null;
54+
parent::prepare($pool, $tuple, $related, $load);
55+
$this->syncMorphKey($pool, $tuple, $hadRelation);
56+
}
57+
58+
public function queue(Pool $pool, Tuple $tuple): void
59+
{
60+
$hadRelation = $tuple->node->getRelation($this->getName()) !== null;
61+
parent::queue($pool, $tuple);
62+
$this->syncMorphKey($pool, $tuple, $hadRelation);
63+
}
64+
65+
/**
66+
* Assert that given entity is allowed for the relation.
67+
*
68+
* @throws RelationException
69+
*/
70+
protected function assertValid(Node $related): void
71+
{
72+
// no need to validate morphed relation yet
73+
}
74+
75+
/**
76+
* Keep the morph key in sync with the related entity role. The role is known as soon as the
77+
* related object is available, so it can be registered eagerly even while the outer key is
78+
* still deferred by the parent {@see RefersTo} logic.
79+
*/
80+
private function syncMorphKey(Pool $pool, Tuple $tuple, bool $hadRelation): void
81+
{
82+
$relName = $this->getName();
83+
$state = $tuple->state;
84+
if (!$state->hasRelation($relName)) {
85+
return;
86+
}
87+
88+
$related = $state->getRelation($relName);
89+
90+
if ($related === null) {
91+
// Reset the morph key when the relation was changed to null
92+
if ($hadRelation) {
93+
$state->register($this->morphKey, null);
94+
}
95+
return;
96+
}
97+
98+
if ($related instanceof EmptyReference) {
99+
return;
100+
}
101+
102+
$role = $related instanceof ReferenceInterface
103+
? $related->getRole()
104+
: $pool->offsetGet($related)?->node->getRole();
105+
106+
$state->register($this->morphKey, $role);
107+
}
108+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
// phpcs:ignoreFile
4+
declare(strict_types=1);
5+
6+
namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;
7+
8+
class EntityA implements MorphedInterface
9+
{
10+
public $id;
11+
public $name;
12+
public $parentId;
13+
public $parentType;
14+
15+
/** @var MorphedInterface|null */
16+
public $parent;
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
// phpcs:ignoreFile
4+
declare(strict_types=1);
5+
6+
namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;
7+
8+
class EntityB implements MorphedInterface
9+
{
10+
public $id;
11+
public $name;
12+
public $parentId;
13+
public $parentType;
14+
15+
/** @var MorphedInterface|null */
16+
public $parent;
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
// phpcs:ignoreFile
4+
declare(strict_types=1);
5+
6+
namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;
7+
8+
interface MorphedInterface {}

tests/ORM/Functional/Driver/Common/Relation/Morphed/BelongsToMorphedRelationTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Cycle\ORM\Heap\Heap;
88
use Cycle\ORM\Mapper\Mapper;
9+
use Cycle\ORM\Options;
910
use Cycle\ORM\Reference\ReferenceInterface;
1011
use Cycle\ORM\Relation;
1112
use Cycle\ORM\Schema;
@@ -374,6 +375,47 @@ public function testUpdateRelation(): void
374375
$this->assertNumReads(0);
375376
}
376377

378+
/**
379+
* With ignoreUninitializedRelations = true (BaseTest default) unsetting the relation property
380+
* must leave both the outer key and the morph key untouched.
381+
*/
382+
public function testUnsetParentKeepsMorphWhenIgnoringUninitialized(): void
383+
{
384+
$c = $this->orm->getRepository(Image::class)->findByPK(1);
385+
$this->assertInstanceOf(User::class, $c->parent);
386+
unset($c->parent);
387+
388+
$this->captureWriteQueries();
389+
$this->save($c);
390+
$this->assertNumWrites(0);
391+
392+
$row = $this->getDatabase()->table('image')->select()->where('id', 1)->fetchAll();
393+
$this->assertSame('1', (string) $row[0]['parent_id']);
394+
$this->assertSame('user', $row[0]['parent_type']);
395+
}
396+
397+
/**
398+
* With ignoreUninitializedRelations = false an unset relation is treated as null, so both the
399+
* outer key and the morph key must be cleared.
400+
*/
401+
public function testUnsetParentClearsMorphWithoutIgnoreUninitialized(): void
402+
{
403+
$this->orm = $this->withSchema(new Schema($this->getNullableMorphedSchemaArray()))
404+
->with(options: (new Options())->withIgnoreUninitializedRelations(false));
405+
406+
$c = $this->orm->getRepository(Image::class)->findByPK(1);
407+
$this->assertInstanceOf(User::class, $c->parent);
408+
unset($c->parent);
409+
410+
$this->captureWriteQueries();
411+
$this->save($c);
412+
$this->assertNumWrites(1);
413+
414+
$row = $this->getDatabase()->table('image')->select()->where('id', 1)->fetchAll();
415+
$this->assertNull($row[0]['parent_id'], 'parent_id should be NULL when the unset relation is treated as null');
416+
$this->assertNull($row[0]['parent_type'], 'parent_type should be NULL when the unset relation is treated as null');
417+
}
418+
377419
public function setUp(): void
378420
{
379421
parent::setUp();

0 commit comments

Comments
 (0)