diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 16653684d..3866cb0ef 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1653,10 +1653,22 @@ - + - + + + + + + + + + + morphKey]]> + + + @@ -2217,14 +2229,22 @@ define(SchemaInterface::COLUMNS)]]> - factory->make($loader->options['scope'])]]> options['minify']]]> options['minify']]]> schema[$key]]]> schema[$key]]]> + $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(), + }]]> + options['as']]]> diff --git a/src/Config/RelationConfig.php b/src/Config/RelationConfig.php index 1c5e8786d..5f1368f4c 100644 --- a/src/Config/RelationConfig.php +++ b/src/Config/RelationConfig.php @@ -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, + ], ]); } diff --git a/src/Relation.php b/src/Relation.php index 72bb27e88..d16d5448b 100644 --- a/src/Relation.php +++ b/src/Relation.php @@ -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 diff --git a/src/Relation/Morphed/RefersToMorphed.php b/src/Relation/Morphed/RefersToMorphed.php new file mode 100644 index 000000000..81c257fdb --- /dev/null +++ b/src/Relation/Morphed/RefersToMorphed.php @@ -0,0 +1,108 @@ + 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); + } +} diff --git a/tests/ORM/Fixtures/MorphedCyclic/EntityA.php b/tests/ORM/Fixtures/MorphedCyclic/EntityA.php new file mode 100644 index 000000000..db9d45c5b --- /dev/null +++ b/tests/ORM/Fixtures/MorphedCyclic/EntityA.php @@ -0,0 +1,17 @@ +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(); diff --git a/tests/ORM/Functional/Driver/Common/Relation/Morphed/RefersToMorphedCyclicTest.php b/tests/ORM/Functional/Driver/Common/Relation/Morphed/RefersToMorphedCyclicTest.php new file mode 100644 index 000000000..3a0fa591e --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Relation/Morphed/RefersToMorphedCyclicTest.php @@ -0,0 +1,342 @@ + A: an entity points to itself through a morphed relation; + * - A > B > A: two entities point at each other through morphed relations. + * + * Unlike {@see Relation::BELONGS_TO_MORPHED} (a hard "parent before child" dependency that + * deadlocks the pool on a cycle), the morphed refers-to relation resolves the outer key in a + * deferred way and therefore is able to persist a closed cycle within a single transaction. + * The related entity is resolved lazily through a promise reference. + */ +abstract class RefersToMorphedCyclicTest extends BaseTest +{ + use TableTrait; + + public function testFetchSelfReference(): void + { + // entity_a #1 references itself + $a = $this->orm->getRepository(EntityA::class)->findByPK(1); + $data = $this->extractEntity($a); + + $this->assertInstanceOf(ReferenceInterface::class, $data['parent']); + + $this->assertInstanceOf(EntityA::class, $a->parent); + $this->assertSame('a-self', $a->parent->name); + $this->assertSame($a->id, $a->parent->id); + } + + public function testFetchCycleTwoEntities(): void + { + // entity_a #2 -> entity_b #1 -> entity_a #2 + $a = $this->orm->getRepository(EntityA::class)->findByPK(2); + + $this->assertInstanceOf(EntityB::class, $a->parent); + $this->assertSame('b-1', $a->parent->name); + + $this->assertInstanceOf(EntityA::class, $a->parent->parent); + $this->assertSame($a->id, $a->parent->parent->id); + } + + /** + * The relation is loaded lazily: a self-reference resolves to the already-loaded entity + * straight from the heap, without issuing an extra query. + */ + public function testLazyLoadSelfReferenceResolvesFromHeap(): void + { + $a = $this->orm->getRepository(EntityA::class)->findByPK(1); + // Not resolved yet — the relation is a promise reference. + $this->assertInstanceOf(ReferenceInterface::class, $this->extractEntity($a)['parent']); + + $this->captureReadQueries(); + $this->assertSame($a, $a->parent); + $this->assertNumReads(0); + } + + /** + * Lazy loading across a morphed cycle: resolving the parent that is not in the heap costs + * exactly one query, while the back-reference is taken from the heap for free. + */ + public function testLazyLoadCycleTwoEntities(): void + { + $a = $this->orm->getRepository(EntityA::class)->findByPK(2); + $this->assertInstanceOf(ReferenceInterface::class, $this->extractEntity($a)['parent']); + + // entity_b #1 is not in the heap yet -> exactly one lazy query. + $this->captureReadQueries(); + $b = $a->parent; + $this->assertInstanceOf(EntityB::class, $b); + $this->assertNumReads(1); + + // The back-reference entity_a #2 is already in the heap -> no extra query. + $this->captureReadQueries(); + $this->assertSame($a, $b->parent); + $this->assertNumReads(0); + } + + /** + * Eager loading via Select::load(): one level of the morphed relation is resolved up front + * (one query per distinct morph role), so accessing the parent afterwards costs no queries. + */ + public function testEagerLoadParent(): void + { + /** @var list $all */ + $all = (new Select($this->orm, EntityA::class)) + ->load('parent') + ->orderBy('entity_a.id') + ->fetchAll(); + + $this->captureReadQueries(); + // #1 references itself, #2 references entity_b #1 — both already loaded. + $this->assertInstanceOf(EntityA::class, $all[0]->parent); + $this->assertSame($all[0], $all[0]->parent); + $this->assertInstanceOf(EntityB::class, $all[1]->parent); + $this->assertSame('b-1', $all[1]->parent->name); + $this->assertNumReads(0); + } + + /** + * The morphed relation can be eagerly loaded for a batch of already-fetched entities through + * the BulkLoader. After that, accessing the parent issues no extra queries. + */ + public function testBulkLoadParent(): void + { + $this->captureReadQueries(); + /** @var list $all */ + $all = (new Select($this->orm, EntityA::class))->orderBy('entity_a.id')->fetchAll(); + $this->assertNumReads(1); + + $this->bulkLoader(...$all)->load('parent')->run(); + + $this->captureReadQueries(); + $this->assertInstanceOf(EntityA::class, $all[0]->parent); + $this->assertSame($all[0], $all[0]->parent); + $this->assertInstanceOf(EntityB::class, $all[1]->parent); + $this->assertSame('b-1', $all[1]->parent->name); + $this->assertNumReads(0); + } + + public function testSetSelfReferenceToNull(): void + { + $a = $this->orm->getRepository(EntityA::class)->findByPK(1); + $this->assertInstanceOf(EntityA::class, $a->parent); + + $a->parent = null; + $this->save($a); + + $row = $this->getDatabase()->table('entity_a')->select()->where('id', 1)->fetchAll(); + $this->assertNull($row[0]['parent_id'], 'parent_id should be NULL after detaching self-reference'); + $this->assertNull($row[0]['parent_type'], 'parent_type should be NULL after detaching self-reference'); + } + + public function testCreateSelfReference(): void + { + $a = new EntityA(); + $a->name = 'new-self'; + $a->parent = $a; + + $this->captureWriteQueries(); + $this->save($a); + // INSERT the row, then a deferred UPDATE with its own id + morph type + $this->assertNumWrites(2); + + // consecutive save does nothing + $this->captureWriteQueries(); + $this->save($a); + $this->assertNumWrites(0); + + $this->assertSame($a->id, $a->parentId); + $this->assertSame('entity_a', $a->parentType); + + $row = $this->getDatabase()->table('entity_a')->select()->where('id', $a->id)->fetchAll(); + $this->assertSame((string) $a->id, (string) $row[0]['parent_id']); + $this->assertSame('entity_a', $row[0]['parent_type']); + + $this->orm = $this->orm->withHeap(new Heap()); + $reloaded = $this->orm->getRepository(EntityA::class)->findByPK($a->id); + $this->assertInstanceOf(EntityA::class, $reloaded->parent); + $this->assertSame($reloaded->id, $reloaded->parent->id); + } + + public function testCreateCycleTwoEntities(): void + { + $a = new EntityA(); + $a->name = 'cycle-a'; + + $b = new EntityB(); + $b->name = 'cycle-b'; + + $a->parent = $b; + $b->parent = $a; + + $this->captureWriteQueries(); + $this->save($a); + // INSERT a, INSERT b, then a deferred UPDATE to close the cycle + $this->assertNumWrites(3); + + // consecutive save does nothing + $this->captureWriteQueries(); + $this->save($a); + $this->assertNumWrites(0); + + $this->assertSame($b->id, $a->parentId); + $this->assertSame('entity_b', $a->parentType); + $this->assertSame($a->id, $b->parentId); + $this->assertSame('entity_a', $b->parentType); + + $this->orm = $this->orm->withHeap(new Heap()); + + $reloadedA = $this->orm->getRepository(EntityA::class)->findByPK($a->id); + $this->assertInstanceOf(EntityB::class, $reloadedA->parent); + $this->assertSame($b->id, $reloadedA->parent->id); + $this->assertInstanceOf(EntityA::class, $reloadedA->parent->parent); + $this->assertSame($a->id, $reloadedA->parent->parent->id); + } + + /** + * With ignoreUninitializedRelations = true (BaseTest default) unsetting the relation property + * must leave both the outer key and the morph key untouched. + */ + public function testUnsetParentKeepsMorphWhenIgnoringUninitialized(): void + { + $a = $this->orm->getRepository(EntityA::class)->findByPK(1); + $this->assertInstanceOf(EntityA::class, $a->parent); + unset($a->parent); + + $this->captureWriteQueries(); + $this->save($a); + $this->assertNumWrites(0); + + $row = $this->getDatabase()->table('entity_a')->select()->where('id', 1)->fetchAll(); + $this->assertSame('1', (string) $row[0]['parent_id']); + $this->assertSame('entity_a', $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->orm->with(options: (new Options())->withIgnoreUninitializedRelations(false)); + + $a = $this->orm->getRepository(EntityA::class)->findByPK(1); + $this->assertInstanceOf(EntityA::class, $a->parent); + unset($a->parent); + + $this->captureWriteQueries(); + $this->save($a); + $this->assertNumWrites(1); + + $row = $this->getDatabase()->table('entity_a')->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(); + + $this->makeTable('entity_a', [ + 'id' => 'primary', + 'name' => 'string', + 'parent_id' => 'integer,nullable', + 'parent_type' => 'string,nullable', + ]); + + $this->makeTable('entity_b', [ + 'id' => 'primary', + 'name' => 'string', + 'parent_id' => 'integer,nullable', + 'parent_type' => 'string,nullable', + ]); + + // entity_a #1 references itself; entity_a #2 -> entity_b #1 + $this->getDatabase()->table('entity_a')->insertMultiple( + ['name', 'parent_id', 'parent_type'], + [ + ['a-self', 1, 'entity_a'], + ['a-cycle', 1, 'entity_b'], + ], + ); + + // entity_b #1 -> entity_a #2 (closes the A > B > A cycle) + $this->getDatabase()->table('entity_b')->insertMultiple( + ['name', 'parent_id', 'parent_type'], + [ + ['b-1', 2, 'entity_a'], + ], + ); + + $this->orm = $this->withSchema(new Schema([ + EntityA::class => [ + Schema::ROLE => 'entity_a', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'entity_a', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => [ + 'id' => 'id', + 'name' => 'name', + 'parentId' => 'parent_id', + 'parentType' => 'parent_type', + ], + Schema::SCHEMA => [], + Schema::RELATIONS => [ + 'parent' => $this->morphedParent(), + ], + ], + EntityB::class => [ + Schema::ROLE => 'entity_b', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'entity_b', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => [ + 'id' => 'id', + 'name' => 'name', + 'parentId' => 'parent_id', + 'parentType' => 'parent_type', + ], + Schema::SCHEMA => [], + Schema::RELATIONS => [ + 'parent' => $this->morphedParent(), + ], + ], + ])); + } + + private function morphedParent(): array + { + return [ + Relation::TYPE => Relation::REFERS_TO_MORPHED, + Relation::TARGET => MorphedInterface::class, + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::NULLABLE => true, + Relation::CASCADE => true, + Relation::OUTER_KEY => 'id', + Relation::INNER_KEY => 'parentId', + Relation::MORPH_KEY => 'parentType', + ], + ]; + } +} diff --git a/tests/ORM/Functional/Driver/MySQL/Relation/Morphed/RefersToMorphedCyclicTest.php b/tests/ORM/Functional/Driver/MySQL/Relation/Morphed/RefersToMorphedCyclicTest.php new file mode 100644 index 000000000..f1eae2139 --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Relation/Morphed/RefersToMorphedCyclicTest.php @@ -0,0 +1,17 @@ +