diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index cfabfd7e6b964..585c8823bb8e7 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -158,8 +158,20 @@ public function __call( $name, $arguments ) { * comment objects (false). Default false. * @type array $date_query Date query clauses to limit comments by. See WP_Date_Query. * Default null. - * @type string $fields Comment fields to return. Accepts 'ids' for comment IDs - * only or empty for all fields. Default empty. + * @type string $fields Which fields to return. Accepts: + * - '' (empty) Returns an array of `WP_Comment` objects (default). + * - 'ids' Returns a flat array of comment IDs (`int[]`). + * - Any column of the `$wpdb->comments` table (e.g. + * 'comment_post_ID', 'user_id', 'comment_parent') Returns + * a flat array of distinct values for that column. + * - A 'col_a=>col_b' pair of column names (e.g. + * 'comment_ID=>comment_post_ID') Returns an associative + * array keyed by the first column's value with the second + * column's value as the entry. + * Column names must be passed in their exact case, except + * the `ID` segment of `comment_ID` / `comment_post_ID`, + * which is accepted in any case. + * Default empty. * @type array $include_unapproved Array of IDs or email addresses of users whose unapproved * comments will be returned by the query regardless of * `$status`. Default empty. @@ -490,6 +502,12 @@ public function get_comments() { return $this->comments; } + $field_selection = $this->parse_fields( $this->query_vars['fields'] ); + if ( null !== $field_selection ) { + $this->comments = $this->get_comment_field_values( $comment_ids, $field_selection ); + return $this->comments; + } + _prime_comment_caches( $comment_ids, false ); // Fetch full comment objects from the primed cache. @@ -998,6 +1016,130 @@ protected function get_comment_ids() { } } + /** + * Normalizes the `fields` query var into a single column name, or a pair of + * column names for a `'col_a=>col_b'` map. Returns null when `fields` is the + * default empty value, `'ids'`, or anything else this method does not + * recognize. + * + * Column names must be passed in their exact case; the only exception is + * the `ID` segment of `comment_ID` / `comment_post_ID`, which is accepted in + * any case. + * + * @since 7.1.0 + * + * @param mixed $fields Raw `fields` query var. + * @return string|string[]|null Single column name, two-element array of + * [key column, value column] for a map, or null. + */ + private function parse_fields( $fields ) { + if ( ! is_string( $fields ) || '' === $fields || 'ids' === $fields ) { + return null; + } + + // 'col_a=>col_b' — associative array, keyed by col_a, valued by col_b. + if ( str_contains( $fields, '=>' ) ) { + list( $key, $value ) = array_map( 'trim', explode( '=>', $fields, 2 ) ); + + $key = $this->parse_field_column( $key ); + $value = $this->parse_field_column( $value ); + + if ( null === $key || null === $value ) { + return null; + } + + return array( $key, $value ); + } + + return $this->parse_field_column( $fields ); + } + + /** + * Returns the canonical column name for a `fields` value, or null if it is + * not a known column of `$wpdb->comments`. See `parse_fields()` for the case + * rules. + * + * @since 7.1.0 + * + * @param string $field + * @return string|null + */ + private function parse_field_column( $field ) { + static $columns = array( + 'comment_ID', + 'comment_post_ID', + 'comment_author', + 'comment_author_email', + 'comment_author_url', + 'comment_author_IP', + 'comment_date', + 'comment_date_gmt', + 'comment_content', + 'comment_karma', + 'comment_approved', + 'comment_agent', + 'comment_type', + 'comment_parent', + 'user_id', + ); + + if ( in_array( $field, $columns, true ) ) { + return $field; + } + + // Accept any case for the `ID` segment of `comment_ID` / `comment_post_ID`. + if ( preg_match( '/^(comment(?:_post)?_)([iI][dD])$/', $field, $m ) ) { + return $m[1] . 'ID'; + } + + return null; + } + + /** + * Returns column values from the comments table for a given list of comment + * IDs. + * + * For a single column, the result is a flat array with `DISTINCT` applied so + * callers can drop the surrounding `array_unique( wp_list_pluck( ... ) )`. + * For a `col_a=>col_b` map, the result is an associative array keyed by + * col_a's value; on duplicate keys, later rows overwrite earlier ones (the + * standard PHP array semantics). + * + * @since 7.1.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[] $comment_ids Comment IDs to fetch values for. + * @param string|string[] $fields Single column name, or [key column, value column]. + * @return array + */ + private function get_comment_field_values( $comment_ids, $fields ) { + global $wpdb; + + if ( empty( $comment_ids ) ) { + return array(); + } + + $ids_sql = implode( ',', array_map( 'intval', $comment_ids ) ); + + if ( is_string( $fields ) ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return $wpdb->get_col( "SELECT DISTINCT `$fields` FROM $wpdb->comments WHERE comment_ID IN ($ids_sql)" ); + } + + list( $key_column, $value_column ) = $fields; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( "SELECT `$key_column`, `$value_column` FROM $wpdb->comments WHERE comment_ID IN ($ids_sql)", ARRAY_N ); + + $map = array(); + foreach ( $rows as $row ) { + $map[ $row[0] ] = $row[1]; + } + + return $map; + } + /** * Populates found_comments and max_num_pages properties for the current * query if the limit clause was used. diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index dc870a78ae494..f8f1cd83b4971 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5534,4 +5534,217 @@ public function test_get_comment_count_excludes_note_type() { $this->assertSame( 1, $counts['all'] ); $this->assertSame( 1, $counts['total_comments'] ); } + + /** + * A column-name fields value returns a distinct, flat array of that + * column's values — replacing the common `array_unique( wp_list_pluck() )` + * pattern. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_single_column_returns_distinct_values() { + $post1 = self::factory()->post->create(); + $post2 = self::factory()->post->create(); + + // Two comments on post1, one on post2 — expect two distinct post IDs. + self::factory()->comment->create( array( 'comment_post_ID' => $post1 ) ); + self::factory()->comment->create( array( 'comment_post_ID' => $post1 ) ); + self::factory()->comment->create( array( 'comment_post_ID' => $post2 ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => 'comment_post_ID' ) ); + + $this->assertIsArray( $found ); + $this->assertSameSets( array( (string) $post1, (string) $post2 ), $found ); + } + + /** + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_single_column_user_id() { + $user_id = self::factory()->user->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => $user_id, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'user_id' => $user_id, + ) + ); + + $q = new WP_Comment_Query(); + $found = $q->query( + array( + 'user_id' => $user_id, + 'fields' => 'user_id', + ) + ); + + $this->assertSame( array( (string) $user_id ), $found ); + } + + /** + * Column names must be passed in exact case; only the `ID` segment of + * `comment_ID` / `comment_post_ID` accepts arbitrary case. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_id_segment_case_is_flexible() { + $post = self::factory()->post->create(); + self::factory()->comment->create( array( 'comment_post_ID' => $post ) ); + + foreach ( array( 'comment_post_ID', 'comment_post_id', 'comment_post_Id' ) as $variant ) { + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => $variant ) ); + $this->assertSame( array( (string) $post ), $found, "Variant: $variant" ); + } + } + + /** + * Non-`ID` columns must be passed in their exact case; mismatched case is + * treated as an unknown column and falls back to full WP_Comment objects. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_non_id_column_case_is_strict() { + self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $q = new WP_Comment_Query(); + // 'comment_author_IP' is the canonical form; 'comment_author_ip' must not project. + $found = $q->query( array( 'fields' => 'comment_author_ip' ) ); + + $this->assertNotEmpty( $found ); + $this->assertInstanceOf( 'WP_Comment', $found[0] ); + } + + /** + * `'col_a=>col_b'` returns an associative array keyed by col_a's value + * with col_b's value as the entry. Mirrors WP_Query / WP_Term_Query's + * `'id=>parent'` idiom. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_map_syntax_id_to_post() { + $post = self::factory()->post->create(); + $c1 = self::factory()->comment->create( array( 'comment_post_ID' => $post ) ); + $c2 = self::factory()->comment->create( array( 'comment_post_ID' => $post ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( + array( + 'post_id' => $post, + 'fields' => 'comment_ID=>comment_post_ID', + ) + ); + + $this->assertIsArray( $found ); + $this->assertCount( 2, $found ); + $this->assertArrayHasKey( $c1, $found ); + $this->assertArrayHasKey( $c2, $found ); + $this->assertEquals( $post, $found[ $c1 ] ); + $this->assertEquals( $post, $found[ $c2 ] ); + } + + /** + * The map form accepts the same case rules as single-column form: the `ID` + * suffix is case-flexible on either side of `=>`. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_map_syntax_accepts_id_case_variants() { + $post = self::factory()->post->create(); + $c1 = self::factory()->comment->create( array( 'comment_post_ID' => $post ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => 'comment_id=>comment_post_id' ) ); + + $this->assertArrayHasKey( $c1, $found ); + $this->assertEquals( $post, $found[ $c1 ] ); + } + + /** + * A `=>` with an unknown column on either side is not a valid map and + * falls back to full `WP_Comment` objects. + * + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_map_syntax_unknown_column_falls_back() { + self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => 'comment_ID=>not_a_column' ) ); + + $this->assertNotEmpty( $found ); + $this->assertInstanceOf( 'WP_Comment', $found[0] ); + } + + /** + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_unknown_column_falls_back_to_full_objects() { + self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => 'not_a_real_column' ) ); + + $this->assertNotEmpty( $found ); + $this->assertInstanceOf( 'WP_Comment', $found[0] ); + } + + /** + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_ids_behavior_is_unchanged() { + $c1 = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $c2 = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + + $q = new WP_Comment_Query(); + $found = $q->query( array( 'fields' => 'ids' ) ); + + $this->assertSameSets( array( $c1, $c2 ), $found ); + // Legacy contract: comment IDs are returned as integers, not strings. + foreach ( $found as $id ) { + $this->assertIsInt( $id ); + } + } + + /** + * @ticket 65313 + * + * @covers WP_Comment_Query::query + */ + public function test_fields_empty_result_set_returns_empty_array() { + $q = new WP_Comment_Query(); + $found = $q->query( + array( + 'post_id' => PHP_INT_MAX, + 'fields' => 'comment_post_ID', + ) + ); + + $this->assertSame( array(), $found ); + } }