Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 144 additions & 2 deletions src/wp-includes/class-wp-comment-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
213 changes: 213 additions & 0 deletions tests/phpunit/tests/comment/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}