From 055b0b61f7cc04463253fac8668ab55f545b228e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 2 Oct 2026 16:30:43 +0400 Subject: [PATCH 1/2] test(has-many): reproduce children detached on reorder in a nullable relation Reordering, prepending to or replacing inside a nullable HasMany collection nulls the foreign key of children that stay in it, because the `calcDeleted()` comparator never returns a positive value and `array_udiff()` reports live items as removed (#575). Assisted-By: Claude Opus 5.5 --- .../Common/Integration/Case575/CaseTest.php | 137 ++++++++++++++++++ .../Integration/Case575/Entity/Comment.php | 20 +++ .../Integration/Case575/Entity/User.php | 21 +++ .../Common/Integration/Case575/schema.php | 81 +++++++++++ .../MySQL/Integration/Case575/CaseTest.php | 17 +++ .../Postgres/Integration/Case575/CaseTest.php | 17 +++ .../Integration/Case575/CaseTest.php | 17 +++ .../SQLite/Integration/Case575/CaseTest.php | 17 +++ 8 files changed, 327 insertions(+) create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case575/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/Comment.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/User.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case575/schema.php create mode 100644 tests/ORM/Functional/Driver/MySQL/Integration/Case575/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Integration/Case575/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLServer/Integration/Case575/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Integration/Case575/CaseTest.php diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case575/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case575/CaseTest.php new file mode 100644 index 00000000..c3d83939 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case575/CaseTest.php @@ -0,0 +1,137 @@ +fetchUser(); + $user->comments = \array_reverse($user->comments); + + $this->captureWriteQueries(); + $this->save($user); + $this->assertOwnedComments(['comment 1', 'comment 2', 'comment 3']); + $this->assertNumWrites(0); + } + + public function testPrependKeepsChildren(): void + { + $user = $this->fetchUser(); + $comment = new Entity\Comment('comment 4'); + $comment->user = $user; + \array_unshift($user->comments, $comment); + + $this->captureWriteQueries(); + $this->save($user); + $this->assertOwnedComments(['comment 1', 'comment 2', 'comment 3', 'comment 4']); + $this->assertNumWrites(1); + } + + public function testReplaceInTheMiddleDetachesOnlyReplacedChild(): void + { + $user = $this->fetchUser(); + $user->comments[1]->user = null; + $comment = new Entity\Comment('comment 4'); + $comment->user = $user; + $user->comments[1] = $comment; + + $this->captureWriteQueries(); + $this->save($user); + $this->assertOwnedComments(['comment 1', 'comment 3', 'comment 4']); + $this->assertNumWrites(2); + $this->assertSame( + ['comment 2'], + $this->getDatabase()->table('comment') + ->select('content') + ->where('user_id', null) + ->fetchAll(\PDO::FETCH_COLUMN), + ); + } + + public function setUp(): void + { + // Init DB + parent::setUp(); + $this->makeTables(); + $this->fillData(); + + $this->loadSchema(__DIR__ . '/schema.php'); + } + + private function fetchUser(): Entity\User + { + $user = (new Select($this->orm, Entity\User::class)) + ->load('comments') + ->wherePK(1) + ->fetchOne(); + + $this->assertInstanceOf(Entity\User::class, $user); + $this->assertCount(3, $user->comments); + + return $user; + } + + /** + * @param list $expected Contents of the user's comments, in id order. + */ + private function assertOwnedComments(array $expected): void + { + $user = (new Select($this->orm->withHeap(new Heap()), Entity\User::class)) + ->load('comments') + ->wherePK(1) + ->fetchOne(); + + $this->assertSame( + $expected, + \array_map(static fn(Entity\Comment $comment): string => $comment->content, $user->comments), + ); + } + + private function makeTables(): void + { + $this->makeTable(Entity\User::ROLE, [ + 'id' => 'primary', + 'login' => 'string', + ]); + + $this->makeTable(Entity\Comment::ROLE, [ + 'id' => 'primary', + 'content' => 'string', + 'user_id' => 'int,nullable', + ]); + $this->makeFK(Entity\Comment::ROLE, 'user_id', Entity\User::ROLE, 'id', 'NO ACTION', 'NO ACTION'); + } + + private function fillData(): void + { + $this->getDatabase()->table('user')->insertMultiple( + ['login'], + [ + ['user-1'], + ], + ); + $this->getDatabase()->table('comment')->insertMultiple( + ['user_id', 'content'], + [ + [1, 'comment 1'], + [1, 'comment 2'], + [1, 'comment 3'], + ], + ); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/Comment.php b/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/Comment.php new file mode 100644 index 00000000..ab1e2916 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/Comment.php @@ -0,0 +1,20 @@ +content = $content; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/User.php b/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/User.php new file mode 100644 index 00000000..c0501e8b --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case575/Entity/User.php @@ -0,0 +1,21 @@ + */ + public array $comments = []; + + public function __construct(string $login) + { + $this->login = $login; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case575/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Case575/schema.php new file mode 100644 index 00000000..4b8d43d4 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case575/schema.php @@ -0,0 +1,81 @@ + [ + Schema::ENTITY => User::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::REPOSITORY => Repository::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'login' => 'login', + ], + Schema::RELATIONS => [ + 'comments' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => Comment::ROLE, + Relation::COLLECTION_TYPE => 'array', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => true, + Relation::WHERE => [], + Relation::ORDER_BY => ['id' => 'ASC'], + Relation::INNER_KEY => ['id'], + Relation::OUTER_KEY => 'user_id', + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'int', + ], + Schema::SCHEMA => [], + ], + Comment::ROLE => [ + Schema::ENTITY => Comment::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::REPOSITORY => Repository::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'content' => 'content', + 'user_id' => 'user_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => User::ROLE, + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => true, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'int', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + ], +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Case575/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Case575/CaseTest.php new file mode 100644 index 00000000..65a4f7fb --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Case575/CaseTest.php @@ -0,0 +1,17 @@ + Date: Fri, 2 Oct 2026 16:33:01 +0400 Subject: [PATCH 2/2] fix(has-many): detect removed children by object id lookup The `array_udiff()` comparator returned only 0 or -1, so the sort-based diff reported children that stayed in the collection as removed, and a nullable relation nulled their foreign keys. A lookup by `spl_object_id()` needs no ordering contract and runs in linear time. Fixes #575. Assisted-By: Claude Opus 5.5 --- src/Relation/HasMany.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Relation/HasMany.php b/src/Relation/HasMany.php index 3d655d8f..4bcf08bf 100644 --- a/src/Relation/HasMany.php +++ b/src/Relation/HasMany.php @@ -242,13 +242,18 @@ protected function getReferenceScope(Node $node): ?array */ protected function calcDeleted(iterable $related, iterable $original): array { - $related = $this->extract($related); - $original = $this->extract($original); - return \array_udiff( - $original ?? [], - $related, - // static fn(object $a, object $b): int => strcmp(spl_object_hash($a), spl_object_hash($b)) - static fn(object $a, object $b): int => (int) ($a === $b) - 1, - ); + $relatedIds = []; + foreach ($this->extract($related) as $item) { + $relatedIds[\spl_object_id($item)] = true; + } + + $deleted = []; + foreach ($this->extract($original) as $item) { + if (!isset($relatedIds[\spl_object_id($item)])) { + $deleted[] = $item; + } + } + + return $deleted; } }