diff --git a/CHANGELOG.md b/CHANGELOG.md index a4180a6ae09..53aba57ba5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft CMS 5 +## Unreleased + +- Fixed a bug where entries could deadlock when saving their authors. ([#15768](https://github.com/craftcms/cms/issues/15768)) + ## 5.10.13.1 - 2026-08-04 - Fixed a bug where sanitized SVGs wouldn’t render. ([#19368](https://github.com/craftcms/cms/issues/19368)) diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 9bf451da6bc..49e877aa57b 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -3147,7 +3147,7 @@ private function _saveAuthors(): void $this->_oldAuthorIds = array_map(fn($id) => (int)$id, $oldAuthorIds); } - Db::delete(Table::ENTRIES_AUTHORS, ['entryId' => $this->id]); + Db::deleteIfExists(Table::ENTRIES_AUTHORS, ['entryId' => $this->id]); if (!empty($this->_authorIds)) { $data = []; diff --git a/tests/unit/services/EntriesTest.php b/tests/unit/services/EntriesTest.php index 2ff4ae42b13..b294a458c72 100644 --- a/tests/unit/services/EntriesTest.php +++ b/tests/unit/services/EntriesTest.php @@ -8,6 +8,9 @@ namespace crafttests\unit\services; use Craft; +use craft\db\Command; +use craft\db\Query; +use craft\db\Table; use craft\elements\Entry; use craft\elements\User; use craft\services\Entries; @@ -15,6 +18,7 @@ use crafttests\fixtures\EntryFixture; use crafttests\fixtures\UserFixture; use UnitTester; +use yii\base\Event; /** * Unit tests for the Entries service. @@ -86,6 +90,105 @@ public function testReassignEntries(): void self::assertSame($this->userB->id, $untouchedEntry->getAuthorId()); } + /** + * @throws \Throwable + */ + public function testSaveAuthorsAvoidsDeletingMissingRows(): void + { + $entry = new Entry([ + 'sectionId' => 1000, + 'typeId' => 1000, + 'title' => 'Save Authors Test', + 'authorId' => $this->userA->id, + ]); + + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); + $entryId = $entry->id; + self::assertNotNull($entryId); + + self::assertSame(['INSERT'], $commands); + self::assertSame([ + [$this->userA->id, 1], + ], $this->_savedAuthors($entryId)); + + $entry->authorId = $this->userB->id; + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); + + self::assertSame(['DELETE', 'INSERT'], $commands); + self::assertSame([ + [$this->userB->id, 1], + ], $this->_savedAuthors($entryId)); + + $section = $entry->getSection(); + self::assertNotNull($section); + $oldMinAuthors = $section->minAuthors; + $section->minAuthors = 0; + $authorlessEntry = new Entry([ + 'sectionId' => 1000, + 'typeId' => 1000, + 'title' => 'Save Without Authors Test', + 'authorIds' => [], + ]); + + try { + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($authorlessEntry)); + } finally { + $section->minAuthors = $oldMinAuthors; + } + $authorlessEntryId = $authorlessEntry->id; + self::assertNotNull($authorlessEntryId); + + self::assertSame([], $commands); + self::assertSame([], $this->_savedAuthors($authorlessEntryId)); + } + + /** + * @param callable(): mixed $callback + * @return string[] + */ + private function _captureAuthorWriteCommands(callable $callback): array + { + $commands = []; + $handler = static function(Event $event) use (&$commands): void { + /** @var Command $command */ + $command = $event->sender; + $sql = ltrim($command->getSql()); + if ( + str_contains($sql, 'entries_authors') && + preg_match('/^(DELETE|INSERT)\b/i', $sql, $matches) + ) { + $commands[] = strtoupper($matches[1]); + } + }; + + Event::on(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); + try { + $callback(); + } finally { + Event::off(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); + } + + return $commands; + } + + /** + * @return int[][] + */ + private function _savedAuthors(int $entryId): array + { + $rows = (new Query()) + ->select(['authorId', 'sortOrder']) + ->from(Table::ENTRIES_AUTHORS) + ->where(['entryId' => $entryId]) + ->orderBy(['sortOrder' => SORT_ASC]) + ->all(); + + return array_map( + fn(array $row) => [(int)$row['authorId'], (int)$row['sortOrder']], + $rows, + ); + } + /** * @inheritdoc */