From ca7e1cf15f4d7d1bd86de77f71c63c5320290a70 Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 30 Sep 2026 20:54:19 +0200 Subject: [PATCH] Lists tied moderation log entries in a defined order Every column the moderation and administration logs sort by can tie, the time column included for entries logged in the same second, and the query left the order of tied entries to the database. The engines disagree: MySQL listed them oldest first in the default newest-first view, and PostgreSQL's order changes when a row is rewritten. Ties now fall back to the order the entries were logged, reversed along with the column. Co-Authored-By: Claude Opus 5.5 Signed-off-by: albertlast --- Sources/Actions/Moderation/Logs.php | 3 +- tests/Integration/ModLogTest.php | 148 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/ModLogTest.php diff --git a/Sources/Actions/Moderation/Logs.php b/Sources/Actions/Moderation/Logs.php index 1f70326e90c..6698e528c97 100644 --- a/Sources/Actions/Moderation/Logs.php +++ b/Sources/Actions/Moderation/Logs.php @@ -344,7 +344,8 @@ public static function list_getModLogEntries(int $start, int $items_per_page, st 'reg_group_id' => 0, 'log_type' => $log_type, 'modlog_query' => $modlog_query, - 'sort' => $sort, + // Entries that tie on the sorted column stay in the order they were logged, turned around along with it. + 'sort' => $sort . ', lm.id_action' . (preg_match('/\sDESC$/i', $sort) ? ' DESC' : ''), 'start' => $start, 'max' => $items_per_page, ]), diff --git a/tests/Integration/ModLogTest.php b/tests/Integration/ModLogTest.php new file mode 100644 index 00000000000..93b81e98f19 --- /dev/null +++ b/tests/Integration/ModLogTest.php @@ -0,0 +1,148 @@ +logTwice(); + + $this->assertSame([$newer, $older], $this->listed('lm.log_time DESC')); + } + + /** + * Every column can tie, so every column needs the same tiebreaker. The + * sort a list passes is the column's default or its reverse, and the ties + * turn around along with it. + * + * @param string $sort The ORDER BY clause the list passes. + * @param bool $newest_first Whether the tied entries should come out newest first. + */ + #[DataProvider('sorts')] + public function testTiesFollowTheDirectionOfTheSort(string $sort, bool $newest_first): void + { + [$older, $newer] = $this->logTwice(); + + $this->assertSame($newest_first ? [$newer, $older] : [$older, $newer], $this->listed($sort)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The sorts both log lists offer, each of which ties for two entries made + * by the same moderator at the same time. + * + * @return array The cases. + */ + public static function sorts(): array + { + return [ + 'time, oldest first' => ['lm.log_time', false], + 'action' => ['lm.action', false], + 'action, reversed' => ['lm.action DESC', true], + 'moderator' => ['mem.real_name', false], + 'moderator, reversed' => ['mem.real_name DESC', true], + 'position, reversed' => ['mg.group_name DESC', true], + 'ip' => ['lm.ip', false], + ]; + } + + /****************** + * Internal methods + ******************/ + + /** + * Acts as the administrator, who sees the whole log. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->actingAs($this->adminId()); + $this->time = random_int(1_000_000_000, 1_100_000_000); + } + + /** + * Logs two identical entries in the administration log at the same time. + * + * @return array The older entry's id and the newer one's. + */ + private function logTwice(): array + { + $ids = []; + + for ($i = 0; $i < 2; $i++) { + $ids[] = (int) Db::$db->insert( + 'insert', + '{db_prefix}log_actions', + [ + 'id_log' => 'int', + 'log_time' => 'int', + 'id_member' => 'int', + 'action' => 'string', + 'extra' => 'string', + ], + [[3, $this->time, $this->adminId(), 'modlog_test', '{}']], + ['id_action'], + 1, + ); + } + + return $ids; + } + + /** + * Lists the test's entries from the administration log. + * + * @param string $sort The ORDER BY clause, as the list passes it. + * @return array The ids of the entries, in the order they were listed. + */ + private function listed(string $sort): array + { + return array_keys(Logs::list_getModLogEntries( + 0, + 10, + $sort, + 'lm.log_time = {int:tied_time}', + ['tied_time' => $this->time], + 3, + )); + } +}