diff --git a/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns b/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns new file mode 100644 index 000000000000..1cdec3bca4e9 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-48202-sitemap-backtick-columns @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Sitemaps: Fix SQL error when wp_posts has a column whose name is a reserved SQL keyword. diff --git a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php index a3544d81ea4f..210a8cc66e12 100644 --- a/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php +++ b/projects/plugins/jetpack/modules/sitemaps/sitemap-librarian.php @@ -302,20 +302,19 @@ public function query_posts_after_id( $from_id, $num_posts ) { } $post_types_list = implode( ',', $post_types ); - $columns_list = $this->get_sanitized_post_columns( $wpdb ); + $columns = $this->get_sanitized_post_columns( $wpdb ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPCS: db call ok; no-cache ok. return $wpdb->get_results( $wpdb->prepare( - "SELECT $columns_list + "SELECT {$columns['placeholders']} FROM $wpdb->posts WHERE post_status='publish' AND post_type IN ($post_types_list) AND ID>%d ORDER BY ID ASC LIMIT %d;", - $from_id, - $num_posts + ...array_merge( $columns['columns'], array( $from_id, $num_posts ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -445,41 +444,52 @@ public function query_most_recent_posts( $num_posts ) { $post_types_list = implode( ',', $post_types ); - $columns_list = $this->get_sanitized_post_columns( $wpdb ); + $columns = $this->get_sanitized_post_columns( $wpdb ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPCS: db call ok; no-cache ok. return $wpdb->get_results( $wpdb->prepare( - "SELECT $columns_list + "SELECT {$columns['placeholders']} FROM $wpdb->posts WHERE post_status='publish' AND post_date >= '%s' AND post_type IN ($post_types_list) ORDER BY post_date DESC LIMIT %d;", - $two_days_ago, - $num_posts + ...array_merge( $columns['columns'], array( $two_days_ago, $num_posts ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** - * Returns all columns from the posts table, - * except post_content and post_content_filtered. + * Returns all columns from the posts table, except post_content and post_content_filtered, + * along with a matching list of %i placeholders for safe use in wpdb::prepare(). + * + * Using %i identifier placeholders ensures column names are correctly backtick-quoted, + * which avoids SQL errors when a column name happens to be a reserved keyword + * (e.g. `order`, `key`, `group`). * * @param object $wpdb The WordPress database object. - * @return string The sanitized post columns. + * @return array { + * @type string $placeholders Comma-separated list of %i placeholders, one per column. + * @type string[] $columns The column names to feed into wpdb::prepare(). + * } */ private function get_sanitized_post_columns( $wpdb ) { - $columns = array_filter( - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->get_col( "SHOW COLUMNS FROM $wpdb->posts" ), - function ( $column ) { - return $column !== 'post_content' && $column !== 'post_content_filtered'; - } + $columns = array_values( + array_filter( + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->get_col( "SHOW COLUMNS FROM $wpdb->posts" ), + function ( $column ) { + return $column !== 'post_content' && $column !== 'post_content_filtered'; + } + ) ); - return implode( ',', array_map( 'esc_sql', $columns ) ); + return array( + 'placeholders' => implode( ',', array_fill( 0, count( $columns ), '%i' ) ), + 'columns' => $columns, + ); } } diff --git a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php index 78e8bc94f839..7df41cedbdeb 100644 --- a/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/sitemaps/Jetpack_Sitemap_Librarian_Test.php @@ -164,4 +164,54 @@ public function test_sitemap_librarian_delete_contiguously_named_rows() { $this->assertTrue( $librarian->read_sitemap_data( 'name-2', 'type' ) === null ); $this->assertTrue( $librarian->read_sitemap_data( 'name-3', 'type' ) === null ); } + + /** + * Regression test for https://github.com/Automattic/jetpack/issues/48202 + * + * If wp_posts has a column whose name is a reserved SQL keyword (e.g. `order`), + * the SELECT used by the sitemap query must still succeed because column names + * are passed as %i identifier placeholders to wpdb::prepare(). + * + * @group jetpack-sitemap + */ + #[Group( 'jetpack-sitemap' )] + public function test_query_posts_after_id_handles_reserved_keyword_columns() { + global $wpdb; + + // Check whether the column already exists before adding it. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $column_existed = (bool) $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->posts} LIKE 'order'" ); + + if ( ! $column_existed ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} ADD COLUMN `order` INT NULL" ); + $this->assertEmpty( $wpdb->last_error, 'ALTER TABLE to add `order` column failed.' ); + } + + try { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); + + $librarian = new Jetpack_Sitemap_Librarian(); + $results = $librarian->query_posts_after_id( 0, 10 ); + + // The query must run without raising a SQL error. + $this->assertSame( '', $wpdb->last_error ); + $this->assertIsArray( $results ); + + // And the post we just created should come back. + $ids = wp_list_pluck( $results, 'ID' ); + $this->assertContains( (string) $post_id, array_map( 'strval', $ids ) ); + } finally { + // Only drop the column if we added it — don't mutate a pre-existing schema. + if ( ! $column_existed ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "ALTER TABLE {$wpdb->posts} DROP COLUMN `order`" ); + } + } + } }