From 159593fbdf01db6a5f0b1a96d76d66bf98af91ad Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Tue, 21 Apr 2026 15:32:00 -0600 Subject: [PATCH 01/12] Adds backcompatFixes() method to database API classes Doesn't do anything yet, but it will soon. Signed-off-by: Jon Stovell --- Sources/Db/APIs/MySQL.php | 20 ++++++++++++++++++++ Sources/Db/APIs/PostgreSQL.php | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index d7a3e474977..4bb3249bb74 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -267,6 +267,8 @@ public function quote(string $db_string, array $db_values, ?object $connection = ); } + $db_string = $this->backcompatFixes($db_string); + return $db_string; } @@ -2923,6 +2925,24 @@ protected function replacement__callback(array $matches, array $db_values, objec throw new \Exception(); } + /** + * Helper for $this->quote() that makes any changes to the query string that + * might be required for backward compatibility support. + * + * Assumes $db_string has already been processed by replacement_callback(). + * + * @param string $db_string The database query string. + * @return string Possibly modified version of $db_string. + */ + protected function backcompatFixes(string $db_string): string + { + if (empty(Config::$backward_compatibility)) { + return $db_string; + } + + return $db_string; + } + /** * This function tries to work out additional error information from a back trace. * diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index 6c4c0fa6a95..1332a838fcb 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -326,6 +326,8 @@ public function quote(string $db_string, array $db_values, ?object $connection = ); } + $db_string = $this->backcompatFixes($db_string); + return $db_string; } @@ -2797,6 +2799,24 @@ protected function replacement__callback(array $matches, array $db_values, objec throw new \Exception(); } + /** + * Helper for $this->quote() that makes any changes to the query string that + * might be required for backward compatibility support. + * + * Assumes $db_string has already been processed by replacement_callback(). + * + * @param string $db_string The database query string. + * @return string Possibly modified version of $db_string. + */ + protected function backcompatFixes(string $db_string): string + { + if (empty(Config::$backward_compatibility)) { + return $db_string; + } + + return $db_string; + } + /** * This function tries to work out additional error information from a back trace. * From 59b22b12ec648a4c4588ad1b0c6f0940c2a03867 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Tue, 21 Apr 2026 12:22:57 -0600 Subject: [PATCH 02/12] Replace count_posts (where 0 = true) with posts_count (where 1 = true) Signed-off-by: Jon Stovell --- Sources/Actions/Admin/Boards.php | 2 +- Sources/Actions/Admin/Maintenance.php | 10 +-- Sources/Actions/Admin/Reports.php | 4 +- Sources/Actions/Profile/StatPanel.php | 4 +- Sources/Actions/QuickModeration.php | 8 +-- Sources/Actions/TopicMove2.php | 8 +-- Sources/Actions/TopicRestore.php | 16 ++--- Sources/Board.php | 30 ++++++--- Sources/Category.php | 2 +- Sources/Db/APIs/MySQL.php | 31 +++++++++ Sources/Db/APIs/PostgreSQL.php | 31 +++++++++ Sources/Db/Schema/v3_0/Boards.php | 6 +- .../Migration/v3_0/BoardPostsCount.php | 64 +++++++++++++++++++ Sources/Maintenance/Tools/Upgrade.php | 1 + Sources/Msg.php | 14 ++-- Sources/Topic.php | 4 +- Themes/default/ManageBoards.template.php | 2 +- 17 files changed, 191 insertions(+), 46 deletions(-) create mode 100644 Sources/Maintenance/Migration/v3_0/BoardPostsCount.php diff --git a/Sources/Actions/Admin/Boards.php b/Sources/Actions/Admin/Boards.php index 615c24eb5dd..7c9968f59ef 100644 --- a/Sources/Actions/Admin/Boards.php +++ b/Sources/Actions/Admin/Boards.php @@ -458,7 +458,7 @@ public function editBoard(): void 'is_new' => true, 'name' => Lang::getTxt('mboards_new_board_name', file: 'ManageBoards'), 'description' => '', - 'count_posts' => true, + 'posts_count' => true, 'posts' => 0, 'topics' => 0, 'theme' => 0, diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index 291228cf160..fc539c68e1c 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -1456,7 +1456,7 @@ public function recountPosts(): void FROM {db_prefix}messages AS m JOIN {db_prefix}boards AS b on m.id_board = b.id_board WHERE m.id_member != 0 - AND b.count_posts = 0', + AND b.posts_count = 1', [ ], ); @@ -1474,7 +1474,7 @@ public function recountPosts(): void FROM {db_prefix}messages AS m INNER JOIN {db_prefix}boards AS b ON m.id_board = b.id_board WHERE m.id_member != {int:zero} - AND b.count_posts = {int:zero} + AND b.posts_count != {int:zero} ' . (!empty(Config::$modSettings['recycle_enable']) ? ' AND b.id_board != {int:recycle}' : '') . ' GROUP BY m.id_member LIMIT {int:start}, {int:number}', @@ -1526,7 +1526,7 @@ public function recountPosts(): void FROM {db_prefix}messages AS m INNER JOIN {db_prefix}boards AS b ON m.id_board = b.id_board WHERE m.id_member != {int:zero} - AND b.count_posts = {int:zero} + AND b.posts_count != {int:zero} ' . (!empty(Config::$modSettings['recycle_enable']) ? ' AND b.id_board != {int:recycle}' : '') . ' GROUP BY m.id_member', [ @@ -2077,14 +2077,14 @@ public static function reattributePosts(int $memID, ?string $email = null, ?stri $request = Db::$db->query( 'SELECT COUNT(*) FROM {db_prefix}messages AS m - INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board AND b.count_posts = {int:count_posts}) + INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board AND b.posts_count = {int:posts_count}) WHERE m.id_member = {int:guest_id} AND m.approved = {int:is_approved}' . (!empty($recycle_board) ? ' AND m.id_board != {int:recycled_board}' : '') . (empty($email) ? '' : ' AND m.poster_email = {string:email_address}') . (empty($membername) ? '' : ' AND m.poster_name = {string:member_name}'), [ - 'count_posts' => 0, + 'posts_count' => 1, 'guest_id' => 0, 'email_address' => $email, 'member_name' => $membername, diff --git a/Sources/Actions/Admin/Reports.php b/Sources/Actions/Admin/Reports.php index 48033cc9746..1bbe23615bb 100644 --- a/Sources/Actions/Admin/Reports.php +++ b/Sources/Actions/Admin/Reports.php @@ -261,7 +261,7 @@ public function boards(): void 'redirect' => Lang::getTxt('board_redirect', file: 'Reports'), 'num_topics' => Lang::getTxt('board_num_topics', file: 'Reports'), 'num_posts' => Lang::getTxt('board_num_posts', file: 'Reports'), - 'count_posts' => Lang::getTxt('board_count_posts', file: 'Reports'), + 'posts_count' => Lang::getTxt('board_count_posts', file: 'Reports'), 'theme' => Lang::getTxt('board_theme', file: 'Reports'), 'override_theme' => Lang::getTxt('board_override_theme', file: 'Reports'), 'profile' => Lang::getTxt('board_profile', file: 'Reports'), @@ -302,7 +302,7 @@ public function boards(): void 'redirect' => $board->redirect, 'num_posts' => $board->posts, 'num_topics' => $board->topics, - 'count_posts' => Lang::getTxt(empty($board->count_posts) ? 'yes' : 'no', file: 'General'), + 'posts_count' => Lang::getTxt($board->posts_count ? 'yes' : 'no', file: 'General'), 'theme' => Utils::$context['themes'][$board->theme] ?? Lang::getTxt('none', file: 'General'), 'profile' => Utils::$context['profiles'][$board->profile]['name'], 'override_theme' => Lang::getTxt($board->override_theme ? 'yes' : 'no', file: 'General'), diff --git a/Sources/Actions/Profile/StatPanel.php b/Sources/Actions/Profile/StatPanel.php index 9de24504b95..27585275b58 100644 --- a/Sources/Actions/Profile/StatPanel.php +++ b/Sources/Actions/Profile/StatPanel.php @@ -112,14 +112,14 @@ public function execute(): void FROM {db_prefix}messages AS m INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board) WHERE m.id_member = {int:current_member} - AND b.count_posts = {int:count_enabled} + AND b.posts_count = {int:count_enabled} AND {query_see_board} GROUP BY b.id_board ORDER BY message_count DESC LIMIT 10', [ 'current_member' => Profile::$member->id, - 'count_enabled' => 0, + 'count_enabled' => 1, ], ); diff --git a/Sources/Actions/QuickModeration.php b/Sources/Actions/QuickModeration.php index aeeec4d3ab7..ce1b88b3f30 100644 --- a/Sources/Actions/QuickModeration.php +++ b/Sources/Actions/QuickModeration.php @@ -671,7 +671,7 @@ protected function doMove(): void $countPosts = []; $request = Db::$db->query( - 'SELECT t.id_topic, t.id_board, b.count_posts + 'SELECT t.id_topic, t.id_board, b.posts_count FROM {db_prefix}topics AS t LEFT JOIN {db_prefix}boards AS b ON (t.id_board = b.id_board) WHERE t.id_topic IN ({array_int:move_topic_ids})' . (!empty(Board::$info->id) && !User::$me->allowedTo('move_any') ? ' @@ -692,7 +692,7 @@ protected function doMove(): void } // Does this topic's board count the posts or not? - $countPosts[(int) $row['id_topic']] = empty($row['count_posts']); + $countPosts[(int) $row['id_topic']] = !empty($row['posts_count']); if (!isset($moveTos[$to])) { $moveTos[$to] = []; @@ -716,7 +716,7 @@ protected function doMove(): void if (!empty($moveTos)) { $topicRecounts = []; $request = Db::$db->query( - 'SELECT id_board, count_posts + 'SELECT id_board, posts_count FROM {db_prefix}boards WHERE id_board IN ({array_int:move_boards})', [ @@ -725,7 +725,7 @@ protected function doMove(): void ); while ($row = Db::$db->fetch_assoc($request)) { - $cp = empty($row['count_posts']); + $cp = !empty($row['posts_count']); // Go through all the topics that are being moved to this board. foreach ($moveTos[(int) $row['id_board']] as $topic) { diff --git a/Sources/Actions/TopicMove2.php b/Sources/Actions/TopicMove2.php index 4e46ff4cc98..0d775ab9e50 100644 --- a/Sources/Actions/TopicMove2.php +++ b/Sources/Actions/TopicMove2.php @@ -106,7 +106,7 @@ public function execute(): void // Make sure they can see the board they are trying to move to (and get whether posts count in the target board). $request = Db::$db->query( - 'SELECT b.count_posts, b.name, m.subject + 'SELECT b.posts_count, b.name, m.subject FROM {db_prefix}boards AS b INNER JOIN {db_prefix}topics AS t ON (t.id_topic = {int:current_topic}) INNER JOIN {db_prefix}messages AS m ON (m.id_msg = t.id_first_msg) @@ -221,14 +221,14 @@ public function execute(): void $posterOptions = [ 'id' => User::$me->id, - 'update_post_count' => empty($pcounter), + 'update_post_count' => !empty($pcounter), ]; Msg::create($msgOptions, $topicOptions, $posterOptions); } $request = Db::$db->query( - 'SELECT count_posts + 'SELECT posts_count FROM {db_prefix}boards WHERE id_board = {int:current_board} LIMIT 1', @@ -259,7 +259,7 @@ public function execute(): void } // The board we're moving from counted posts, but not to. - if (empty($pcounter_from)) { + if (!empty($pcounter_from)) { $posters[$row['id_member']]--; } // The reverse: from didn't, to did. diff --git a/Sources/Actions/TopicRestore.php b/Sources/Actions/TopicRestore.php index 35abc76fa1d..eb45288c6e6 100644 --- a/Sources/Actions/TopicRestore.php +++ b/Sources/Actions/TopicRestore.php @@ -72,7 +72,7 @@ public function execute(): void // Get the id_previous_board and id_previous_topic. $request = Db::$db->query( 'SELECT m.id_topic, m.id_msg, m.id_board, m.subject, m.id_member, t.id_previous_board, t.id_previous_topic, - t.id_first_msg, b.count_posts, COALESCE(pt.id_board, 0) AS possible_prev_board + t.id_first_msg, b.posts_count, COALESCE(pt.id_board, 0) AS possible_prev_board FROM {db_prefix}messages AS m INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic) INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board) @@ -106,7 +106,7 @@ public function execute(): void if (empty($actioned_messages[$row['id_previous_topic']])) { $actioned_messages[$row['id_previous_topic']] = [ 'msgs' => [], - 'count_posts' => $row['count_posts'], + 'posts_count' => $row['posts_count'], 'subject' => $row['subject'], 'previous_board' => $row['id_previous_board'], 'possible_prev_board' => $row['possible_prev_board'], @@ -212,17 +212,17 @@ public function execute(): void // Lets see if the board that we are returning to has post count enabled. $request2 = Db::$db->query( - 'SELECT count_posts + 'SELECT posts_count FROM {db_prefix}boards WHERE id_board = {int:board}', [ 'board' => $row['id_previous_board'], ], ); - list($count_posts) = Db::$db->fetch_row($request2); + list($posts_count) = Db::$db->fetch_row($request2); Db::$db->free_result($request2); - if (empty($count_posts)) { + if (!empty($posts_count)) { $members = []; // Lets get the members that need their post count restored. @@ -313,7 +313,7 @@ protected static function mergePosts(array|int $msgs, int $from_topic, int $targ // Get some target topic and board stats. $request = Db::$db->query( - 'SELECT t.id_board, t.id_first_msg, t.num_replies, t.unapproved_posts, b.count_posts + 'SELECT t.id_board, t.id_first_msg, t.num_replies, t.unapproved_posts, b.posts_count FROM {db_prefix}topics AS t INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) WHERE t.id_topic = {int:target_topic}', @@ -321,11 +321,11 @@ protected static function mergePosts(array|int $msgs, int $from_topic, int $targ 'target_topic' => $target_topic, ], ); - list($target_board, $target_first_msg, $target_replies, $target_unapproved_posts, $count_posts) = Db::$db->fetch_row($request); + list($target_board, $target_first_msg, $target_replies, $target_unapproved_posts, $posts_count) = Db::$db->fetch_row($request); Db::$db->free_result($request); // Lets see if the board that we are returning to has post count enabled. - if (empty($count_posts)) { + if (!empty($posts_count)) { // Lets get the members that need their post count restored. $members = User::loadCustom( query_customizations: [ diff --git a/Sources/Board.php b/Sources/Board.php index 17543250c35..50d5c57a492 100644 --- a/Sources/Board.php +++ b/Sources/Board.php @@ -254,7 +254,7 @@ class Board implements \ArrayAccess, Routable * * Whether posts in this board count toward a user's total post count. */ - public bool $count_posts = true; + public bool $posts_count = true; /** * @var bool @@ -423,7 +423,6 @@ class Board implements \ArrayAccess, Routable 'id_theme' => 'theme', 'board_theme' => 'theme', 'id_profile' => 'profile', - 'posts_count' => 'count_posts', 'href' => 'url', 'id_last_msg' => 'last_msg', 'id_msg_updated' => 'msg_updated', @@ -434,6 +433,7 @@ class Board implements \ArrayAccess, Routable // Initial exclamation mark means inverse of the property. 'is_read' => '!new', + 'count_posts' => '!posts_count', ]; /**************************** @@ -470,7 +470,7 @@ class Board implements \ArrayAccess, Routable 'profile', 'redirect', 'recycle', - 'count_posts', + 'posts_count', 'cur_topic_approved', 'cur_topic_starter', ], @@ -1275,7 +1275,7 @@ public static function modify(int $board_id, array &$boardOptions): void $board->profile = (int) ($boardOptions['profile'] ?? $board->profile ?? 1); // Boolean properties. - $board->count_posts = !empty($boardOptions['posts_count'] ?? $board->count_posts ?? true); + $board->posts_count = !empty($boardOptions['posts_count'] ?? $board->posts_count ?? true); $board->override_theme = !empty($boardOptions['override_theme'] ?? $board->override_theme ?? false); // Array properties. @@ -2328,7 +2328,7 @@ protected function loadBoardInfo(): void break; case 'override_theme': - case 'count_posts': + case 'posts_count': $props[$key] = !empty($value); break; @@ -2522,7 +2522,7 @@ protected function saveNew(): void 'description' => 'string', 'num_topics' => 'int', 'num_posts' => 'int', - 'count_posts' => 'int', + 'posts_count' => 'int', 'id_theme' => 'int', 'override_theme' => 'int', 'unapproved_posts' => 'int', @@ -2544,7 +2544,7 @@ protected function saveNew(): void $this->description, $this->num_topics, $this->num_posts, - (int) $this->count_posts, + (int) $this->posts_count, $this->theme, (int) $this->override_theme, $this->unapproved_posts, @@ -2642,7 +2642,7 @@ protected function saveExisting(int $level): void 'id_profile = {int:profile}', 'name = {string:board_name}', 'description = {string:board_description}', - 'count_posts = {int:count_posts}', + 'posts_count = {int:posts_count}', 'id_theme = {int:board_theme}', 'override_theme = {int:override_theme}', 'redirect = {string:redirect}', @@ -2659,15 +2659,27 @@ protected function saveExisting(int $level): void 'profile' => $this->profile, 'board_name' => $this->name, 'board_description' => $this->description, - 'count_posts' => (int) $this->count_posts, + 'posts_count' => (int) $this->posts_count, 'board_theme' => $this->theme, 'override_theme' => (int) $this->override_theme, 'redirect' => $this->redirect, ], ); + // Old mods would have expected $params['count_posts'], which had + // an inverted value (i.e. 0 = true, 1 = false). + if (!empty(Config::$backward_compatibility)) { + $params['count_posts'] = (int) !$this->posts_count; + } + // Do any hooks want to add or adjust anything? IntegrationHook::call('integrate_modify_board', [$this->id, $this->internal_data['boardOptions'] ?? [], &$set, &$params]); + + // Clean up the backward compatibility changes. + if (!empty(Config::$backward_compatibility)) { + $params['posts_count'] = (int) empty($params['count_posts']); + unset($params['count_posts']); + } } // Perform the update. diff --git a/Sources/Category.php b/Sources/Category.php index 1c11dbd096f..8b0b026d2e5 100644 --- a/Sources/Category.php +++ b/Sources/Category.php @@ -698,7 +698,7 @@ public static function getTree(): void 'COALESCE(b.id_board, 0) AS id_board', 'b.name', 'b.description', 'b.id_parent', 'b.child_level', 'b.board_order', 'b.redirect', 'b.member_groups', 'b.deny_member_groups', 'b.id_profile', - 'b.id_theme', 'b.override_theme', 'b.count_posts', 'b.num_posts', + 'b.id_theme', 'b.override_theme', 'b.posts_count', 'b.num_posts', 'b.num_topics', 'c.id_cat', 'c.cat_order', 'c.can_collapse', 'c.name AS cat_name', 'c.description AS cat_desc', ]; diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index 4bb3249bb74..435604d49ca 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -2940,6 +2940,37 @@ protected function backcompatFixes(string $db_string): string return $db_string; } + // Prior to SMF 3.0, the boards table contained a 'count_posts' column + // that used inverted logic (i.e. 0 = true, 1 = false). That column was + // replaced in 3.0 with a 'posts_count' column that uses normal logic + // (i.e. 0 = false, 1 = true). This code detects references to the old + // column and replaces them with references to the new column. + if ( + str_contains($db_string, 'count_posts') + && preg_match('/\b' . preg_quote($this->prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) + ) { + $old_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'count_posts'; + $new_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'posts_count'; + + $db_string = preg_replace_callback_array( + [ + '/\b' . $old_col . '\s*(!=|<(?:=|>)?|=|>=?)\s*([01])\b/' => function ($m) { + $m[1] = match ($m[1]) { + '>' => '<', + '>=' => '<=', + '<' => '>', + '>=' => '<=', + default => $m[1], + }; + + return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); + }, + '/\b' . $old_col . '\b/' => fn($m) => $new_col, + ], + $db_string, + ); + } + return $db_string; } diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index 1332a838fcb..cf7256c4b3f 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -2814,6 +2814,37 @@ protected function backcompatFixes(string $db_string): string return $db_string; } + // Prior to SMF 3.0, the boards table contained a 'count_posts' column + // that used inverted logic (i.e. 0 = true, 1 = false). That column was + // replaced in 3.0 with a 'posts_count' column that uses normal logic + // (i.e. 0 = false, 1 = true). This code detects references to the old + // column and replaces them with references to the new column. + if ( + str_contains($db_string, 'count_posts') + && preg_match('/\b' . preg_quote($this->prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) + ) { + $old_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'count_posts'; + $new_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'posts_count'; + + $db_string = preg_replace_callback_array( + [ + '/\b' . $old_col . '\s*(!=|<(?:=|>)?|=|>=?)\s*([01])\b/' => function ($m) { + $m[1] = match ($m[1]) { + '>' => '<', + '>=' => '<=', + '<' => '>', + '>=' => '<=', + default => $m[1], + }; + + return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); + }, + '/\b' . $old_col . '\b/' => fn($m) => $new_col, + ], + $db_string, + ); + } + return $db_string; } diff --git a/Sources/Db/Schema/v3_0/Boards.php b/Sources/Db/Schema/v3_0/Boards.php index 136fbab854c..a41be8cae61 100644 --- a/Sources/Db/Schema/v3_0/Boards.php +++ b/Sources/Db/Schema/v3_0/Boards.php @@ -148,11 +148,11 @@ public function __construct() not_null: true, default: 0, ), - 'count_posts' => new Column( - name: 'count_posts', + 'posts_count' => new Column( + name: 'posts_count', type: 'tinyint', not_null: true, - default: 0, + default: 1, ), 'id_theme' => new Column( name: 'id_theme', diff --git a/Sources/Maintenance/Migration/v3_0/BoardPostsCount.php b/Sources/Maintenance/Migration/v3_0/BoardPostsCount.php new file mode 100644 index 00000000000..082ec0ec810 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/BoardPostsCount.php @@ -0,0 +1,64 @@ +getCurrentStructure(); + + return isset($existing_structure['columns']['count_posts']); + } + + /** + * + */ + public function execute(): bool + { + $table = new Schema\v3_0\Boards(); + $table->addColumn($table->columns['posts_count']); + + $this->query( + 'UPDATE {db_prefix}boards + SET posts_count = CASE WHEN count_posts = 0 THEN 1 ELSE 0 END', + ); + + $table->dropColumn('count_posts'); + + return true; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index 903517bd1f3..a18d138e8e3 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -180,6 +180,7 @@ class Upgrade extends ToolsBase implements ToolsInterface Migration\v3_0\MailType::class, Migration\v3_0\RemoveCookieTime::class, Migration\v3_0\PermissionChanges::class, + Migration\v3_0\BoardPostsCount::class, ], ]; diff --git a/Sources/Msg.php b/Sources/Msg.php index e4c663d8248..2f725bb9aa3 100644 --- a/Sources/Msg.php +++ b/Sources/Msg.php @@ -1370,7 +1370,7 @@ public static function approve(array|int $msgs, bool $approve = true, bool $noti $request = Db::$db->query( 'SELECT m.id_msg, m.approved, m.id_topic, m.id_board, t.id_first_msg, t.id_last_msg, m.body, m.subject, COALESCE(mem.real_name, m.poster_name) AS poster_name, m.id_member, - t.approved AS topic_approved, b.count_posts + t.approved AS topic_approved, b.posts_count FROM {db_prefix}messages AS m INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic) INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board) @@ -1459,7 +1459,7 @@ public static function approve(array|int $msgs, bool $approve = true, bool $noti $board_changes[$row['id_board']]['posts'] += $approve ? 1 : -1; // Post count for the user? - if ($row['id_member'] && empty($row['count_posts'])) { + if ($row['id_member'] && !empty($row['posts_count'])) { $member_post_changes[$row['id_member']] = isset($member_post_changes[$row['id_member']]) ? $member_post_changes[$row['id_member']] + 1 : 1; } } @@ -1848,7 +1848,7 @@ public static function remove(int $message, bool $decreasePostCount = true): boo m.id_member, m.icon, m.poster_time, m.subject, m.body, m.approved, t.id_topic, t.id_first_msg, t.id_last_msg, t.num_replies, t.id_board, t.id_member_started AS id_member_poster, - b.count_posts + b.posts_count FROM {db_prefix}messages AS m INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic) INNER JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board) @@ -1867,6 +1867,12 @@ public static function remove(int $message, bool $decreasePostCount = true): boo $row = Db::$db->fetch_assoc($request); Db::$db->free_result($request); + // Old mods would have expected $row['count_posts'], which had + // an inverted value (i.e. 0 = true, 1 = false). + if (!empty(Config::$backward_compatibility)) { + $row['count_posts'] = (int) empty($row['posts_count']); + } + // Give mods a heads-up before we do anything. IntegrationHook::call('integrate_pre_remove_message', [$message, $decreasePostCount, $row]); @@ -2236,7 +2242,7 @@ public static function remove(int $message, bool $decreasePostCount = true): boo // If the poster was registered and the board this message was on incremented // the member's posts when it was posted, decrease his or her post count. - if (!empty($row['id_member']) && $decreasePostCount && empty($row['count_posts']) && $row['approved']) { + if (!empty($row['id_member']) && $decreasePostCount && !empty($row['posts_count']) && $row['approved']) { $member = current(User::load((int) $row['id_member'], dataset: UserDataset::Minimal)); $member->posts--; $member->save(); diff --git a/Sources/Topic.php b/Sources/Topic.php index 7cc629e4e57..67803a86893 100644 --- a/Sources/Topic.php +++ b/Sources/Topic.php @@ -1516,11 +1516,11 @@ public static function remove(array|int $topics, bool $decreasePostCount = true, INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board) WHERE m.id_topic IN ({array_int:topics})' . (!empty($recycle_board) ? ' AND m.id_board != {int:recycled_board}' : '') . ' - AND b.count_posts = {int:do_count_posts} + AND b.posts_count = {int:do_count_posts} AND m.approved = {int:is_approved} GROUP BY m.id_member', [ - 'do_count_posts' => 0, + 'do_count_posts' => 1, 'recycled_board' => $recycle_board, 'topics' => $topics, 'is_approved' => 1, diff --git a/Themes/default/ManageBoards.template.php b/Themes/default/ManageBoards.template.php index 98fb35c5266..52575075760 100644 --- a/Themes/default/ManageBoards.template.php +++ b/Themes/default/ManageBoards.template.php @@ -522,7 +522,7 @@ function template_modify_board() ', Lang::getTxt('mboards_count_posts_desc', file: 'ManageBoards'), '
- +
'; From a4aec80ebad75bc1cad9808049034ed9a938776e Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 12:04:49 -0600 Subject: [PATCH 03/12] Implements backcompatInsertFixes() in database APIs Signed-off-by: Jon Stovell --- Sources/Db/APIs/MySQL.php | 53 +++++++++++++++++++++++++++-- Sources/Db/APIs/PostgreSQL.php | 53 +++++++++++++++++++++++++++-- Sources/Db/DatabaseApiInterface.php | 2 +- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index 435604d49ca..637013edbb3 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -267,7 +267,7 @@ public function quote(string $db_string, array $db_values, ?object $connection = ); } - $db_string = $this->backcompatFixes($db_string); + $db_string = $this->backcompatQuoteFixes($db_string); return $db_string; } @@ -392,6 +392,9 @@ public function insert(string $method, string $table, array $columns, array $dat } } + // Apply any adjustments needed for backward compatibility. + [$columns, $data, $keys] = $this->backcompatInsertFixes($table, $columns, $data, $keys); + // Create the mold for a single row insert. $insertData = '('; @@ -2934,7 +2937,7 @@ protected function replacement__callback(array $matches, array $db_values, objec * @param string $db_string The database query string. * @return string Possibly modified version of $db_string. */ - protected function backcompatFixes(string $db_string): string + protected function backcompatQuoteFixes(string $db_string): string { if (empty(Config::$backward_compatibility)) { return $db_string; @@ -2974,6 +2977,52 @@ protected function backcompatFixes(string $db_string): string return $db_string; } + /** + * Helper for $this->insert() that makes any changes to the columns, data, + * and/or keys that might be required for backward compatibility support. + * + * @param string $table The table. + * @param array $columns Array of the columns we're inserting the data into. + * Should contain 'column' => 'datatype' pairs. + * @param array $data Rows of data to insert. Each element of $data must + * be an array of values corresponding to $columns. + * @param array $keys The keys for the table. + * @return array Updated versions $columns, $data, and $keys. + */ + protected function backcompatInsertFixes(string $table, array $columns, array $data, array $keys): array + { + if (empty(Config::$backward_compatibility)) { + return [$columns, $data, $keys]; + } + + // Prior to SMF 3.0, the boards table contained a 'count_posts' column + // that used inverted logic (i.e. 0 = true, 1 = false). That column was + // replaced in 3.0 with a 'posts_count' column that uses normal logic + // (i.e. 0 = false, 1 = true). This code detects references to the old + // column and replaces them with references to the new column. + if ($table === $this->prefix . 'boards') { + if (isset($columns['count_posts'])) { + $pos = array_search('count_posts', array_keys($columns)); + + foreach ($data as $row_num => $row) { + $data[$row_num][$pos] = (int) !$row[$pos]; + } + + $columns = array_merge( + \array_slice($columns, 0, $pos), + ['posts_count' => 'int'], + \array_slice($columns, $pos + 1), + ); + } + + if (\in_array('count_posts', $keys)) { + $keys[array_search('count_posts', $keys)] = 'posts_count'; + } + } + + return [$columns, $data, $keys]; + } + /** * This function tries to work out additional error information from a back trace. * diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index cf7256c4b3f..0c2a48e4f51 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -326,7 +326,7 @@ public function quote(string $db_string, array $db_values, ?object $connection = ); } - $db_string = $this->backcompatFixes($db_string); + $db_string = $this->backcompatQuoteFixes($db_string); return $db_string; } @@ -465,6 +465,9 @@ public function insert(string $method, string $table, array $columns, array $dat $with_returning = true; } + // Apply any adjustments needed for backward compatibility. + [$columns, $data, $keys] = $this->backcompatInsertFixes($table, $columns, $data, $keys); + if (!empty($data)) { // Create the mold for a single row insert. $insertData = '('; @@ -2808,7 +2811,7 @@ protected function replacement__callback(array $matches, array $db_values, objec * @param string $db_string The database query string. * @return string Possibly modified version of $db_string. */ - protected function backcompatFixes(string $db_string): string + protected function backcompatQuoteFixes(string $db_string): string { if (empty(Config::$backward_compatibility)) { return $db_string; @@ -2848,6 +2851,52 @@ protected function backcompatFixes(string $db_string): string return $db_string; } + /** + * Helper for $this->insert() that makes any changes to the columns, data, + * and/or keys that might be required for backward compatibility support. + * + * @param string $table The table. + * @param array $columns Array of the columns we're inserting the data into. + * Should contain 'column' => 'datatype' pairs. + * @param array $data Rows of data to insert. Each element of $data must + * be an array of values corresponding to $columns. + * @param array $keys The keys for the table. + * @return array Updated versions $columns, $data, and $keys. + */ + protected function backcompatInsertFixes(string $table, array $columns, array $data, array $keys): array + { + if (empty(Config::$backward_compatibility)) { + return [$columns, $data, $keys]; + } + + // Prior to SMF 3.0, the boards table contained a 'count_posts' column + // that used inverted logic (i.e. 0 = true, 1 = false). That column was + // replaced in 3.0 with a 'posts_count' column that uses normal logic + // (i.e. 0 = false, 1 = true). This code detects references to the old + // column and replaces them with references to the new column. + if ($table === $this->prefix . 'boards') { + if (isset($columns['count_posts'])) { + $pos = array_search('count_posts', array_keys($columns)); + + foreach ($data as $row_num => $row) { + $data[$row_num][$pos] = (int) !$row[$pos]; + } + + $columns = array_merge( + \array_slice($columns, 0, $pos), + ['posts_count' => 'int'], + \array_slice($columns, $pos + 1), + ); + } + + if (\in_array('count_posts', $keys)) { + $keys[array_search('count_posts', $keys)] = 'posts_count'; + } + } + + return [$columns, $data, $keys]; + } + /** * This function tries to work out additional error information from a back trace. * diff --git a/Sources/Db/DatabaseApiInterface.php b/Sources/Db/DatabaseApiInterface.php index 254c9508c89..5c7b1c78e5a 100644 --- a/Sources/Db/DatabaseApiInterface.php +++ b/Sources/Db/DatabaseApiInterface.php @@ -89,7 +89,7 @@ public function free_result(object $result): bool; * returns the resulting IDs. * * @param string $method INSERT or REPLACE. - * @param string $table The table (only used for Postgres). + * @param string $table The table. * @param array $columns Array of the columns we're inserting the data into. * Should contain 'column' => 'datatype' pairs. * @param array $data Rows of data to insert. Each element of $data must From 365eab0cd15dcead597ab7cc6a31bfa394a3494a Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 12:04:16 -0600 Subject: [PATCH 04/12] Misc fixes for backcompatQuoteFixes() in database APIs Signed-off-by: Jon Stovell --- Sources/Db/APIs/MySQL.php | 10 +++++----- Sources/Db/APIs/PostgreSQL.php | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index 637013edbb3..10ae1484961 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -2950,25 +2950,25 @@ protected function backcompatQuoteFixes(string $db_string): string // column and replaces them with references to the new column. if ( str_contains($db_string, 'count_posts') - && preg_match('/\b' . preg_quote($this->prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) + && preg_match('/\b' . preg_quote(Config::$db_prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) ) { - $old_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'count_posts'; + $old_col = (!empty($matches[1]) ? '(?:' . $matches[1] . '\.)?' : '') . 'count_posts'; $new_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'posts_count'; $db_string = preg_replace_callback_array( [ - '/\b' . $old_col . '\s*(!=|<(?:=|>)?|=|>=?)\s*([01])\b/' => function ($m) { + '/(?)?|=|>=?)\s*([01])\b/' => function ($m) use ($new_col) { $m[1] = match ($m[1]) { '>' => '<', '>=' => '<=', '<' => '>', - '>=' => '<=', + '<=' => '>=', default => $m[1], }; return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); }, - '/\b' . $old_col . '\b/' => fn($m) => $new_col, + '/(? fn($m) => $new_col, ], $db_string, ); diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index 0c2a48e4f51..a8215a960e2 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -2824,25 +2824,25 @@ protected function backcompatQuoteFixes(string $db_string): string // column and replaces them with references to the new column. if ( str_contains($db_string, 'count_posts') - && preg_match('/\b' . preg_quote($this->prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) + && preg_match('/\b' . preg_quote(Config::$db_prefix) . 'boards\b(?:\s+AS\s+(\w+))?/i', $db_string, $matches) ) { - $old_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'count_posts'; + $old_col = (!empty($matches[1]) ? '(?:' . $matches[1] . '\.)?' : '') . 'count_posts'; $new_col = (!empty($matches[1]) ? $matches[1] . '.' : '') . 'posts_count'; $db_string = preg_replace_callback_array( [ - '/\b' . $old_col . '\s*(!=|<(?:=|>)?|=|>=?)\s*([01])\b/' => function ($m) { + '/(?)?|=|>=?)\s*([01])\b/' => function ($m) use ($new_col) { $m[1] = match ($m[1]) { '>' => '<', '>=' => '<=', '<' => '>', - '>=' => '<=', + '<=' => '>=', default => $m[1], }; return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); }, - '/\b' . $old_col . '\b/' => fn($m) => $new_col, + '/(? fn($m) => $new_col, ], $db_string, ); From 1ede7151aff069a626ae7abe77004c6780b80bab Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 20:16:13 -0600 Subject: [PATCH 05/12] Respects changes to posts_count made via integrate_modify_board hook Signed-off-by: Jon Stovell --- Sources/Board.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/Board.php b/Sources/Board.php index 50d5c57a492..91d8f55c110 100644 --- a/Sources/Board.php +++ b/Sources/Board.php @@ -2669,7 +2669,7 @@ protected function saveExisting(int $level): void // Old mods would have expected $params['count_posts'], which had // an inverted value (i.e. 0 = true, 1 = false). if (!empty(Config::$backward_compatibility)) { - $params['count_posts'] = (int) !$this->posts_count; + $params['count_posts'] = $count_posts = (int) !$this->posts_count; } // Do any hooks want to add or adjust anything? @@ -2677,7 +2677,15 @@ protected function saveExisting(int $level): void // Clean up the backward compatibility changes. if (!empty(Config::$backward_compatibility)) { - $params['posts_count'] = (int) empty($params['count_posts']); + // If posts_count did not change, but count_posts did, then + // sync the change to count_posts back to posts_count. + if ( + (int) $params['posts_count'] === (int) $this->posts_count + && (int) $params['count_posts'] !== (int) $count_posts + ) { + $params['posts_count'] = (int) empty($params['count_posts']); + } + unset($params['count_posts']); } } From 98860127262662cb461abadd6da316d64f03bd2e Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 20:22:05 -0600 Subject: [PATCH 06/12] Fixes bug where members' post counts drifted in QuickModeration Signed-off-by: Jon Stovell --- Sources/Actions/QuickModeration.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/Actions/QuickModeration.php b/Sources/Actions/QuickModeration.php index ce1b88b3f30..2d9761084c8 100644 --- a/Sources/Actions/QuickModeration.php +++ b/Sources/Actions/QuickModeration.php @@ -740,7 +740,7 @@ protected function doMove(): void Db::$db->free_result($request); if (!empty($topicRecounts)) { - $members = []; + $adjustments = []; // Get all the members who have posted in the moved topics. $request = Db::$db->query( @@ -754,22 +754,22 @@ protected function doMove(): void ); while ($row = Db::$db->fetch_assoc($request)) { - if (!isset($members[$row['id_member']])) { - $members[(int) $row['id_member']] = 0; + if (!isset($adjustments[$row['id_member']])) { + $adjustments[(int) $row['id_member']] = 0; } if ($topicRecounts[(int) $row['id_topic']] === '+') { - $members[(int) $row['id_member']]++; + $adjustments[(int) $row['id_member']]++; } else { - $members[(int) $row['id_member']]--; + $adjustments[(int) $row['id_member']]--; } - - $members[(int) $row['id_member']] = max(0, $members[(int) $row['id_member']]); } Db::$db->free_result($request); // And now update the member's post counts. - foreach ($members as $id => $post_adj) { + $members = []; + + foreach ($adjustments as $id => $post_adj) { $members[$id] = current(User::load($id, dataset: UserDataset::Minimal)); if ($members[$id] instanceof User) { From f4da52c849f938b165e5e35bcd89a0b633bb0c76 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 20:37:43 -0600 Subject: [PATCH 07/12] Fixes bug where members' post counts drifted in TopicMove2 Signed-off-by: Jon Stovell --- Sources/Actions/TopicMove2.php | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/Sources/Actions/TopicMove2.php b/Sources/Actions/TopicMove2.php index 0d775ab9e50..e54b324ea1a 100644 --- a/Sources/Actions/TopicMove2.php +++ b/Sources/Actions/TopicMove2.php @@ -221,25 +221,14 @@ public function execute(): void $posterOptions = [ 'id' => User::$me->id, - 'update_post_count' => !empty($pcounter), + 'update_post_count' => (bool) Board::$info->posts_count, ]; Msg::create($msgOptions, $topicOptions, $posterOptions); } - $request = Db::$db->query( - 'SELECT posts_count - FROM {db_prefix}boards - WHERE id_board = {int:current_board} - LIMIT 1', - [ - 'current_board' => Board::$info->id, - ], - ); - list($pcounter_from) = Db::$db->fetch_row($request); - Db::$db->free_result($request); - - if ($pcounter_from != $pcounter) { + // If one of the boards counts posts and the other doesn't, we have more work to do. + if (Board::$info->posts_count != $pcounter) { $posters = []; $request = Db::$db->query( @@ -259,7 +248,7 @@ public function execute(): void } // The board we're moving from counted posts, but not to. - if (!empty($pcounter_from)) { + if (Board::$info->posts_count) { $posters[$row['id_member']]--; } // The reverse: from didn't, to did. From 894b260d44a2065e5b472c3518809812303616eb Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 14 Aug 2026 20:48:02 -0600 Subject: [PATCH 08/12] Fixes bugs recounting posts in Actions\Admin\Maintenance Signed-off-by: Jon Stovell --- Sources/Actions/Admin/Maintenance.php | 34 ++++++++++++++------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index fc539c68e1c..cff45f1d111 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -1455,9 +1455,12 @@ public function recountPosts(): void 'SELECT COUNT(DISTINCT m.id_member) FROM {db_prefix}messages AS m JOIN {db_prefix}boards AS b on m.id_board = b.id_board - WHERE m.id_member != 0 - AND b.posts_count = 1', + WHERE m.id_member != {int:zero} + AND b.posts_count = {int:one} + AND m.approved = {int:one}', [ + 'zero' => 0, + 'one' => 1, ], ); @@ -1474,7 +1477,8 @@ public function recountPosts(): void FROM {db_prefix}messages AS m INNER JOIN {db_prefix}boards AS b ON m.id_board = b.id_board WHERE m.id_member != {int:zero} - AND b.posts_count != {int:zero} + AND b.posts_count = {int:one} + AND m.approved = {int:one} ' . (!empty(Config::$modSettings['recycle_enable']) ? ' AND b.id_board != {int:recycle}' : '') . ' GROUP BY m.id_member LIMIT {int:start}, {int:number}', @@ -1483,6 +1487,7 @@ public function recountPosts(): void 'number' => $increment, 'recycle' => Config::$modSettings['recycle_board'], 'zero' => 0, + 'one' => 1, ], ); $total_rows = Db::$db->num_rows($request); @@ -1492,11 +1497,8 @@ public function recountPosts(): void Db::$db->query( 'UPDATE {db_prefix}members SET posts = {int:posts} - WHERE id_member = {int:row}', - [ - 'row' => $row['id_member'], - 'posts' => $row['posts'], - ], + WHERE id_member = {int:id_member}', + $row, ); } Db::$db->free_result($request); @@ -1518,26 +1520,26 @@ public function recountPosts(): void // final steps ... made more difficult since we don't yet support sub-selects on joins // place all members who have posts in the message table in a temp table $createTemporary = Db::$db->query( - 'CREATE TEMPORARY TABLE {db_prefix}tmp_maint_recountposts ( - id_member mediumint(8) unsigned NOT NULL default {string:string_zero}, - PRIMARY KEY (id_member) - ) + 'CREATE TEMPORARY TABLE {db_prefix}tmp_maint_recountposts AS SELECT m.id_member FROM {db_prefix}messages AS m INNER JOIN {db_prefix}boards AS b ON m.id_board = b.id_board WHERE m.id_member != {int:zero} - AND b.posts_count != {int:zero} + AND b.posts_count = {int:one} + AND m.approved = {int:one} ' . (!empty(Config::$modSettings['recycle_enable']) ? ' AND b.id_board != {int:recycle}' : '') . ' GROUP BY m.id_member', [ 'zero' => 0, - 'string_zero' => '0', + 'one' => 1, 'db_error_skip' => true, 'recycle' => !empty(Config::$modSettings['recycle_board']) ? Config::$modSettings['recycle_board'] : 0, ], ) !== false; if ($createTemporary) { + Db::$db->add_index('{db_prefix}tmp_maint_recountposts', ['type' => 'primary', 'columns' => ['id_member']]); + // outer join the members table on the temporary table finding the members that have a post count but no posts in the message table $request = Db::$db->query( 'SELECT mem.id_member, mem.posts @@ -1556,9 +1558,9 @@ public function recountPosts(): void Db::$db->query( 'UPDATE {db_prefix}members SET posts = {int:zero} - WHERE id_member = {int:row}', + WHERE id_member = {int:id_member}', [ - 'row' => $row['id_member'], + 'id_member' => $row['id_member'], 'zero' => 0, ], ); From 2706b73873091fee79c370e58e3ce43744c5c5e3 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sat, 15 Aug 2026 11:23:05 -0600 Subject: [PATCH 09/12] Further improves backcompat support for `SELECT count_posts` queries Signed-off-by: Jon Stovell --- Sources/Db/APIs/MySQL.php | 2 ++ Sources/Db/APIs/PostgreSQL.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/Db/APIs/MySQL.php b/Sources/Db/APIs/MySQL.php index 10ae1484961..a1fd12b39cc 100644 --- a/Sources/Db/APIs/MySQL.php +++ b/Sources/Db/APIs/MySQL.php @@ -2957,6 +2957,7 @@ protected function backcompatQuoteFixes(string $db_string): string $db_string = preg_replace_callback_array( [ + '/(\bSELECT\b(?:.(?!\bFROM\b))*)((? fn($m) => $m[1] . $new_col . ', (1 - ' . $new_col . ') AS ' . md5('count_posts'), '/(?)?|=|>=?)\s*([01])\b/' => function ($m) use ($new_col) { $m[1] = match ($m[1]) { '>' => '<', @@ -2969,6 +2970,7 @@ protected function backcompatQuoteFixes(string $db_string): string return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); }, '/(? fn($m) => $new_col, + '/' . md5('count_posts') . '/' => fn($m) => 'count_posts', ], $db_string, ); diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index a8215a960e2..d709223eb3c 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -2831,6 +2831,7 @@ protected function backcompatQuoteFixes(string $db_string): string $db_string = preg_replace_callback_array( [ + '/(\bSELECT\b(?:.(?!\bFROM\b))*)((? fn($m) => $m[1] . $new_col . ', (1 - ' . $new_col . ') AS ' . md5('count_posts'), '/(?)?|=|>=?)\s*([01])\b/' => function ($m) use ($new_col) { $m[1] = match ($m[1]) { '>' => '<', @@ -2843,6 +2844,7 @@ protected function backcompatQuoteFixes(string $db_string): string return $new_col . ' ' . $m[1] . ' ' . ((int) !$m[2]); }, '/(? fn($m) => $new_col, + '/' . md5('count_posts') . '/' => fn($m) => 'count_posts', ], $db_string, ); From d3ad5df4f2ffad902fbef4f19bbd083463dd5072 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 16 Aug 2026 00:46:48 -0600 Subject: [PATCH 10/12] Corrects some documentation Signed-off-by: Jon Stovell --- Sources/Msg.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Msg.php b/Sources/Msg.php index 2f725bb9aa3..a8d2e6fb42a 100644 --- a/Sources/Msg.php +++ b/Sources/Msg.php @@ -2360,7 +2360,7 @@ public static function parseRoute(array $route, array $params = []): array * @param array $params Parameters to substitute into query text. * @param array $joins Zero or more *complete* JOIN clauses. * E.g.: 'LEFT JOIN {db_prefix}categories AS c ON (c.id_cat = b.id_cat)' - * Note that 'FROM {db_prefix}boards AS b' is always part of the query. + * Note that 'FROM {db_prefix}messages AS m' is always part of the query. * @param array $where Zero or more conditions for the WHERE clause. * Conditions will be placed in parentheses and concatenated with AND. * If this is left empty, no WHERE clause will be used. From 8f6b63b4f0b3a829af412190f8b8a9137ee025bd Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 16 Aug 2026 16:29:35 -0600 Subject: [PATCH 11/12] Ensures the temporary table is freshly created during recountPosts() Signed-off-by: Jon Stovell --- Sources/Actions/Admin/Maintenance.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index cff45f1d111..2587a574502 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -1519,6 +1519,8 @@ public function recountPosts(): void // final steps ... made more difficult since we don't yet support sub-selects on joins // place all members who have posts in the message table in a temp table + Db::$db->query('DROP TABLE IF EXISTS {db_prefix}tmp_maint_recountposts'); + $createTemporary = Db::$db->query( 'CREATE TEMPORARY TABLE {db_prefix}tmp_maint_recountposts AS SELECT m.id_member From ccae98e247bb427cf08edb4296e3c0a3224050f7 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 16 Aug 2026 16:36:02 -0600 Subject: [PATCH 12/12] Removes call to $this->list_columns() in PostgreSQL::add_index() Signed-off-by: Jon Stovell --- Sources/Db/APIs/PostgreSQL.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index d709223eb3c..8184ed95f17 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -1382,8 +1382,6 @@ public function add_index(string $table_name, array $index_info, array $paramete } // MySQL you can do a "column_name (length)", postgresql does not allow this. Strip it. - $cols = $this->list_columns($table_name, true); - foreach ($index_info['columns'] as &$c) { if (\is_array($c)) { $c = $c['name'] . (isset($c['opclass']) ? ' ' . $c['opclass'] : '');