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
26 changes: 23 additions & 3 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1653,10 +1653,22 @@
<code><![CDATA[parent::getReferenceScope($node)]]></code>
</PossiblyNullOperand>
</file>
<file src="src/Relation/RefersTo.php">
<file src="src/Relation/Morphed/RefersToMorphed.php">
<ClassMustBeFinal>
<code><![CDATA[RefersTo]]></code>
<code><![CDATA[RefersToMorphed]]></code>
</ClassMustBeFinal>
<MixedArgument>
<code><![CDATA[$related]]></code>
<code><![CDATA[$target]]></code>
<code><![CDATA[$target]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$related]]></code>
<code><![CDATA[$target]]></code>
<code><![CDATA[$this->morphKey]]></code>
</MixedAssignment>
</file>
<file src="src/Relation/RefersTo.php">
<MixedArgument>
<code><![CDATA[$related]]></code>
<code><![CDATA[$related]]></code>
Expand Down Expand Up @@ -2217,14 +2229,22 @@
<MixedArgument>
<code><![CDATA[$row]]></code>
<code><![CDATA[$this->define(SchemaInterface::COLUMNS)]]></code>
<code><![CDATA[$this->factory->make($loader->options['scope'])]]></code>
<code><![CDATA[$this->options['minify']]]></code>
<code><![CDATA[$this->options['minify']]]></code>
<code><![CDATA[$this->schema[$key]]]></code>
<code><![CDATA[$this->schema[$key]]]></code>
<code><![CDATA[match (true) {
$scope instanceof ScopeInterface => $scope,
\is_string($scope) => $this->factory->make($scope),
// false/null explicitly disable the scope for this relation
$scope === false, $scope === null => null,
// true (the default) means: use the relation source's scope
default => $this->source->getScope(),
}]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$row]]></code>
<code><![CDATA[$scope]]></code>
</MixedAssignment>
<MixedReturnStatement>
<code><![CDATA[$this->options['as']]]></code>
Expand Down
4 changes: 4 additions & 0 deletions src/Config/RelationConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public static function getDefault(): self
self::LOADER => Select\Loader\Morphed\BelongsToMorphedLoader::class,
self::RELATION => Relation\Morphed\BelongsToMorphed::class,
],
Relation::REFERS_TO_MORPHED => [
self::LOADER => Select\Loader\Morphed\BelongsToMorphedLoader::class,
self::RELATION => Relation\Morphed\RefersToMorphed::class,
],
]);
}

Expand Down
1 change: 1 addition & 0 deletions src/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class Relation
// Morphed relations
public const BELONGS_TO_MORPHED = 20;
public const MORPHED_HAS_ONE = 21;
public const REFERS_TO_MORPHED = 22;
public const MORPHED_HAS_MANY = 23;

// Custom morph key
Expand Down
108 changes: 108 additions & 0 deletions src/Relation/Morphed/RefersToMorphed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Relation\Morphed;

use Cycle\ORM\Exception\RelationException;
use Cycle\ORM\Heap\Node;
use Cycle\ORM\ORMInterface;
use Cycle\ORM\Reference\EmptyReference;
use Cycle\ORM\Reference\Reference;
use Cycle\ORM\Reference\ReferenceInterface;
use Cycle\ORM\Relation;
use Cycle\ORM\Relation\RefersTo;
use Cycle\ORM\Transaction\Pool;
use Cycle\ORM\Transaction\Tuple;

/**
* Morphed variation of the {@see RefersTo} relation. Like {@see BelongsToMorphed} it stores
* both the outer key and the target role (morph key) on the owner, but inherits the deferred,
* "soft" dependency resolution of {@see RefersTo}. This allows the relation to be self linked
* and to participate in cyclic references (A > A, A > B > A) that {@see BelongsToMorphed} can
* not persist in a single transaction.
*
* @internal
*/
class RefersToMorphed extends RefersTo
{
private string $morphKey;

public function __construct(ORMInterface $orm, string $role, string $name, string $target, array $schema)
{
parent::__construct($orm, $role, $name, $target, $schema);
$this->morphKey = $schema[Relation::MORPH_KEY];
}

public function initReference(Node $node): ReferenceInterface
{
$scope = $this->getReferenceScope($node);
$nodeData = $node->getData();
if (!isset($nodeData[$this->morphKey], $scope)) {
return new EmptyReference('?', null);
}
$target = $nodeData[$this->morphKey];

return $scope === [] ? new EmptyReference($target, null) : new Reference($target, $scope);
}

public function prepare(Pool $pool, Tuple $tuple, mixed $related, bool $load = true): void
{
// The parent RefersTo resets the node relation while handling a null value, so capture
// whether there was a related entity before delegating.
$hadRelation = $tuple->node->getRelation($this->getName()) !== null;
parent::prepare($pool, $tuple, $related, $load);
$this->syncMorphKey($pool, $tuple, $hadRelation);
}

public function queue(Pool $pool, Tuple $tuple): void
{
$hadRelation = $tuple->node->getRelation($this->getName()) !== null;
parent::queue($pool, $tuple);
$this->syncMorphKey($pool, $tuple, $hadRelation);
}

/**
* Assert that given entity is allowed for the relation.
*
* @throws RelationException
*/
protected function assertValid(Node $related): void
{
// no need to validate morphed relation yet
}

/**
* Keep the morph key in sync with the related entity role. The role is known as soon as the
* related object is available, so it can be registered eagerly even while the outer key is
* still deferred by the parent {@see RefersTo} logic.
*/
private function syncMorphKey(Pool $pool, Tuple $tuple, bool $hadRelation): void
{
$relName = $this->getName();
$state = $tuple->state;
if (!$state->hasRelation($relName)) {
return;
}

$related = $state->getRelation($relName);

if ($related === null) {
// Reset the morph key when the relation was changed to null
if ($hadRelation) {
$state->register($this->morphKey, null);
}
return;
}

if ($related instanceof EmptyReference) {
return;
}

$role = $related instanceof ReferenceInterface
? $related->getRole()
: $pool->offsetGet($related)?->node->getRole();

$state->register($this->morphKey, $role);
}
}
17 changes: 17 additions & 0 deletions tests/ORM/Fixtures/MorphedCyclic/EntityA.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

// phpcs:ignoreFile
declare(strict_types=1);

namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;

class EntityA implements MorphedInterface
{
public $id;
public $name;
public $parentId;
public $parentType;

/** @var MorphedInterface|null */
public $parent;
}
17 changes: 17 additions & 0 deletions tests/ORM/Fixtures/MorphedCyclic/EntityB.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

// phpcs:ignoreFile
declare(strict_types=1);

namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;

class EntityB implements MorphedInterface
{
public $id;
public $name;
public $parentId;
public $parentType;

/** @var MorphedInterface|null */
public $parent;
}
8 changes: 8 additions & 0 deletions tests/ORM/Fixtures/MorphedCyclic/MorphedInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

// phpcs:ignoreFile
declare(strict_types=1);

namespace Cycle\ORM\Tests\Fixtures\MorphedCyclic;

interface MorphedInterface {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Cycle\ORM\Heap\Heap;
use Cycle\ORM\Mapper\Mapper;
use Cycle\ORM\Options;
use Cycle\ORM\Reference\ReferenceInterface;
use Cycle\ORM\Relation;
use Cycle\ORM\Schema;
Expand Down Expand Up @@ -374,6 +375,47 @@ public function testUpdateRelation(): void
$this->assertNumReads(0);
}

/**
* With ignoreUninitializedRelations = true (BaseTest default) unsetting the relation property
* must leave both the outer key and the morph key untouched.
*/
public function testUnsetParentKeepsMorphWhenIgnoringUninitialized(): void
{
$c = $this->orm->getRepository(Image::class)->findByPK(1);
$this->assertInstanceOf(User::class, $c->parent);
unset($c->parent);

$this->captureWriteQueries();
$this->save($c);
$this->assertNumWrites(0);

$row = $this->getDatabase()->table('image')->select()->where('id', 1)->fetchAll();
$this->assertSame('1', (string) $row[0]['parent_id']);
$this->assertSame('user', $row[0]['parent_type']);
}

/**
* With ignoreUninitializedRelations = false an unset relation is treated as null, so both the
* outer key and the morph key must be cleared.
*/
public function testUnsetParentClearsMorphWithoutIgnoreUninitialized(): void
{
$this->orm = $this->withSchema(new Schema($this->getNullableMorphedSchemaArray()))
->with(options: (new Options())->withIgnoreUninitializedRelations(false));

$c = $this->orm->getRepository(Image::class)->findByPK(1);
$this->assertInstanceOf(User::class, $c->parent);
unset($c->parent);

$this->captureWriteQueries();
$this->save($c);
$this->assertNumWrites(1);

$row = $this->getDatabase()->table('image')->select()->where('id', 1)->fetchAll();
$this->assertNull($row[0]['parent_id'], 'parent_id should be NULL when the unset relation is treated as null');
$this->assertNull($row[0]['parent_type'], 'parent_type should be NULL when the unset relation is treated as null');
}

public function setUp(): void
{
parent::setUp();
Expand Down
Loading
Loading