Skip to content

Commit 087c972

Browse files
dd32claude
andcommitted
Comments: Allow WP_Comment_Query fields to project arbitrary columns.
Extends the `fields` argument of `WP_Comment_Query` to accept any valid `$wpdb->comments` column (or an array of columns), matching the existing flexibility of `WP_User_Query`. This avoids hydrating full `WP_Comment` objects when the caller only needs one or two columns (e.g. the common `array_unique( wp_list_pluck( $q->comments, 'comment_post_ID' ) )` pattern, which hydrates every matched comment just to discard all but one field). Projections apply `DISTINCT` so callers can drop the surrounding `array_unique()` call. Column names are matched exact-case; the `ID` suffix of `comment_ID` / `comment_post_ID` is the only segment that accepts case variation. `'ids'` and `''` retain their existing meaning; the new behaviour is purely additive. Props dd32. See #65313. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4e28109 commit 087c972

2 files changed

Lines changed: 358 additions & 2 deletions

File tree

src/wp-includes/class-wp-comment-query.php

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,15 @@ public function __call( $name, $arguments ) {
158158
* comment objects (false). Default false.
159159
* @type array $date_query Date query clauses to limit comments by. See WP_Date_Query.
160160
* Default null.
161-
* @type string $fields Comment fields to return. Accepts 'ids' for comment IDs
162-
* only or empty for all fields. Default empty.
161+
* @type string|string[] $fields Which fields to return. Accepts:
162+
* - '' (empty) to return an array of `WP_Comment` objects (default).
163+
* - 'ids' to return a flat array of comment IDs.
164+
* - A single column name from the `$wpdb->comments` table
165+
* (e.g. 'comment_post_ID', 'user_id', 'comment_parent')
166+
* to return a flat array of that column's values.
167+
* - An array of column names to return an array of `stdClass`
168+
* objects with the requested fields populated.
169+
* Default empty.
163170
* @type array $include_unapproved Array of IDs or email addresses of users whose unapproved
164171
* comments will be returned by the query regardless of
165172
* `$status`. Default empty.
@@ -490,6 +497,12 @@ public function get_comments() {
490497
return $this->comments;
491498
}
492499

500+
$projection = $this->parse_fields_projection( $this->query_vars['fields'] );
501+
if ( null !== $projection ) {
502+
$this->comments = $this->pluck_comment_fields( $comment_ids, $projection );
503+
return $this->comments;
504+
}
505+
493506
_prime_comment_caches( $comment_ids, false );
494507

495508
// Fetch full comment objects from the primed cache.
@@ -998,6 +1011,120 @@ protected function get_comment_ids() {
9981011
}
9991012
}
10001013

1014+
/**
1015+
* Normalizes the `fields` query var into a column projection.
1016+
*
1017+
* Returns the canonical column name for a single-column projection, a list
1018+
* of column names for a multi-column projection, or null when `fields` does
1019+
* not request a column projection. Unknown column names are dropped.
1020+
*
1021+
* Column names must be passed in their exact case; the only exception is
1022+
* the `id` segment of `comment_ID` and `comment_post_ID`, which is accepted
1023+
* in any case (e.g. `comment_id`, `comment_Id`, `comment_ID`).
1024+
*
1025+
* @since 6.9.0
1026+
*
1027+
* @param string|string[] $fields Raw `fields` query var.
1028+
* @return string|string[]|null
1029+
*/
1030+
private function parse_fields_projection( $fields ) {
1031+
if ( '' === $fields || 'ids' === $fields || 'all' === $fields ) {
1032+
return null;
1033+
}
1034+
1035+
if ( is_string( $fields ) ) {
1036+
return $this->canonicalize_field_column( $fields );
1037+
}
1038+
1039+
if ( ! is_array( $fields ) ) {
1040+
return null;
1041+
}
1042+
1043+
$columns = array();
1044+
foreach ( $fields as $field ) {
1045+
$col = is_string( $field ) ? $this->canonicalize_field_column( $field ) : null;
1046+
if ( null !== $col ) {
1047+
$columns[ $col ] = $col;
1048+
}
1049+
}
1050+
1051+
return $columns ? array_values( $columns ) : null;
1052+
}
1053+
1054+
/**
1055+
* Returns the canonical column name for a `fields` value, or null if it is
1056+
* not a recognized column. See `parse_fields_projection()` for the case
1057+
* rules.
1058+
*
1059+
* @since 6.9.0
1060+
*
1061+
* @param string $field
1062+
* @return string|null
1063+
*/
1064+
private function canonicalize_field_column( $field ) {
1065+
static $columns = array(
1066+
'comment_ID',
1067+
'comment_post_ID',
1068+
'comment_author',
1069+
'comment_author_email',
1070+
'comment_author_url',
1071+
'comment_author_IP',
1072+
'comment_date',
1073+
'comment_date_gmt',
1074+
'comment_content',
1075+
'comment_karma',
1076+
'comment_approved',
1077+
'comment_agent',
1078+
'comment_type',
1079+
'comment_parent',
1080+
'user_id',
1081+
);
1082+
1083+
if ( in_array( $field, $columns, true ) ) {
1084+
return $field;
1085+
}
1086+
1087+
// Accept any case for the `ID` segment of `comment_ID` / `comment_post_ID`.
1088+
if ( preg_match( '/^(comment(?:_post)?_)([iI][dD])$/', $field, $m ) ) {
1089+
return $m[1] . 'ID';
1090+
}
1091+
1092+
return null;
1093+
}
1094+
1095+
/**
1096+
* Plucks the requested column(s) from the comments table for a given list of
1097+
* comment IDs, applying `DISTINCT` so projections behave like the common
1098+
* `array_unique( wp_list_pluck( ... ) )` pattern they replace.
1099+
*
1100+
* @since 6.9.0
1101+
*
1102+
* @global wpdb $wpdb WordPress database abstraction object.
1103+
*
1104+
* @param int[] $comment_ids Comment IDs to project.
1105+
* @param string|string[] $projection Column name, or array of column names.
1106+
* @return array Flat array of values (single column) or array of stdClass
1107+
* objects (multi-column).
1108+
*/
1109+
private function pluck_comment_fields( $comment_ids, $projection ) {
1110+
global $wpdb;
1111+
1112+
if ( empty( $comment_ids ) ) {
1113+
return array();
1114+
}
1115+
1116+
$ids_sql = implode( ',', array_map( 'intval', $comment_ids ) );
1117+
1118+
if ( is_string( $projection ) ) {
1119+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1120+
return $wpdb->get_col( "SELECT DISTINCT `$projection` FROM $wpdb->comments WHERE comment_ID IN ($ids_sql)" );
1121+
}
1122+
1123+
$select = '`' . implode( '`, `', $projection ) . '`';
1124+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1125+
return $wpdb->get_results( "SELECT DISTINCT $select FROM $wpdb->comments WHERE comment_ID IN ($ids_sql)" );
1126+
}
1127+
10011128
/**
10021129
* Populates found_comments and max_num_pages properties for the current
10031130
* query if the limit clause was used.

tests/phpunit/tests/comment/query.php

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5534,4 +5534,233 @@ public function test_get_comment_count_excludes_note_type() {
55345534
$this->assertSame( 1, $counts['all'] );
55355535
$this->assertSame( 1, $counts['total_comments'] );
55365536
}
5537+
5538+
/**
5539+
* @ticket 65313
5540+
*
5541+
* @covers WP_Comment_Query::query
5542+
*/
5543+
public function test_fields_single_column_projection_returns_distinct_values() {
5544+
$post1 = self::factory()->post->create();
5545+
$post2 = self::factory()->post->create();
5546+
5547+
// Two comments on post1, one on post2 — expect two distinct post IDs.
5548+
self::factory()->comment->create( array( 'comment_post_ID' => $post1 ) );
5549+
self::factory()->comment->create( array( 'comment_post_ID' => $post1 ) );
5550+
self::factory()->comment->create( array( 'comment_post_ID' => $post2 ) );
5551+
5552+
$q = new WP_Comment_Query();
5553+
$found = $q->query( array( 'fields' => 'comment_post_ID' ) );
5554+
5555+
$this->assertIsArray( $found );
5556+
$this->assertSameSets( array( (string) $post1, (string) $post2 ), $found );
5557+
}
5558+
5559+
/**
5560+
* @ticket 65313
5561+
*
5562+
* @covers WP_Comment_Query::query
5563+
*/
5564+
public function test_fields_single_column_projection_user_id() {
5565+
$user_id = self::factory()->user->create();
5566+
5567+
self::factory()->comment->create(
5568+
array(
5569+
'comment_post_ID' => self::$post_id,
5570+
'user_id' => $user_id,
5571+
)
5572+
);
5573+
self::factory()->comment->create(
5574+
array(
5575+
'comment_post_ID' => self::$post_id,
5576+
'user_id' => $user_id,
5577+
)
5578+
);
5579+
5580+
$q = new WP_Comment_Query();
5581+
$found = $q->query(
5582+
array(
5583+
'user_id' => $user_id,
5584+
'fields' => 'user_id',
5585+
)
5586+
);
5587+
5588+
$this->assertSame( array( (string) $user_id ), $found );
5589+
}
5590+
5591+
/**
5592+
* Column names must be passed in exact case; only the `ID` segment of
5593+
* `comment_ID` / `comment_post_ID` accepts arbitrary case.
5594+
*
5595+
* @ticket 65313
5596+
*
5597+
* @covers WP_Comment_Query::query
5598+
*/
5599+
public function test_fields_id_segment_case_is_flexible() {
5600+
$post = self::factory()->post->create();
5601+
self::factory()->comment->create( array( 'comment_post_ID' => $post ) );
5602+
5603+
foreach ( array( 'comment_post_ID', 'comment_post_id', 'comment_post_Id' ) as $variant ) {
5604+
$q = new WP_Comment_Query();
5605+
$found = $q->query( array( 'fields' => $variant ) );
5606+
$this->assertSame( array( (string) $post ), $found, "Variant: $variant" );
5607+
}
5608+
}
5609+
5610+
/**
5611+
* Non-`ID` columns must be passed in their exact case; mismatched case is
5612+
* treated as an unknown column and falls back to full WP_Comment objects.
5613+
*
5614+
* @ticket 65313
5615+
*
5616+
* @covers WP_Comment_Query::query
5617+
*/
5618+
public function test_fields_non_id_column_case_is_strict() {
5619+
self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
5620+
5621+
$q = new WP_Comment_Query();
5622+
// 'comment_author_IP' is the canonical form; 'comment_author_ip' must not project.
5623+
$found = $q->query( array( 'fields' => 'comment_author_ip' ) );
5624+
5625+
$this->assertNotEmpty( $found );
5626+
$this->assertInstanceOf( 'WP_Comment', $found[0] );
5627+
}
5628+
5629+
/**
5630+
* @ticket 65313
5631+
*
5632+
* @covers WP_Comment_Query::query
5633+
*/
5634+
public function test_fields_multi_column_projection_returns_stdclass_rows() {
5635+
$post = self::factory()->post->create();
5636+
5637+
$c1 = self::factory()->comment->create(
5638+
array(
5639+
'comment_post_ID' => $post,
5640+
'comment_approved' => '1',
5641+
)
5642+
);
5643+
$c2 = self::factory()->comment->create(
5644+
array(
5645+
'comment_post_ID' => $post,
5646+
'comment_approved' => '1',
5647+
)
5648+
);
5649+
5650+
$q = new WP_Comment_Query();
5651+
$found = $q->query(
5652+
array(
5653+
'post_id' => $post,
5654+
'fields' => array( 'comment_ID', 'comment_post_ID' ),
5655+
'orderby' => 'comment_ID',
5656+
'order' => 'ASC',
5657+
)
5658+
);
5659+
5660+
$this->assertCount( 2, $found );
5661+
$this->assertInstanceOf( 'stdClass', $found[0] );
5662+
$this->assertObjectHasProperty( 'comment_ID', $found[0] );
5663+
$this->assertObjectHasProperty( 'comment_post_ID', $found[0] );
5664+
5665+
$ids = wp_list_pluck( $found, 'comment_ID' );
5666+
$this->assertEqualSets( array( $c1, $c2 ), array_map( 'intval', $ids ) );
5667+
}
5668+
5669+
/**
5670+
* @ticket 65313
5671+
*
5672+
* @covers WP_Comment_Query::query
5673+
*/
5674+
public function test_fields_multi_column_projection_is_distinct() {
5675+
$post1 = self::factory()->post->create( array( 'post_status' => 'publish' ) );
5676+
$post2 = self::factory()->post->create( array( 'post_status' => 'publish' ) );
5677+
5678+
// Three comments approved on post1 (same comment_post_ID + comment_approved),
5679+
// one approved on post2. Distinct combinations: 2.
5680+
self::factory()->comment->create(
5681+
array(
5682+
'comment_post_ID' => $post1,
5683+
'comment_approved' => '1',
5684+
)
5685+
);
5686+
self::factory()->comment->create(
5687+
array(
5688+
'comment_post_ID' => $post1,
5689+
'comment_approved' => '1',
5690+
)
5691+
);
5692+
self::factory()->comment->create(
5693+
array(
5694+
'comment_post_ID' => $post1,
5695+
'comment_approved' => '1',
5696+
)
5697+
);
5698+
self::factory()->comment->create(
5699+
array(
5700+
'comment_post_ID' => $post2,
5701+
'comment_approved' => '1',
5702+
)
5703+
);
5704+
5705+
$q = new WP_Comment_Query();
5706+
$found = $q->query(
5707+
array(
5708+
'post__in' => array( $post1, $post2 ),
5709+
'fields' => array( 'comment_post_ID', 'comment_approved' ),
5710+
)
5711+
);
5712+
5713+
$this->assertCount( 2, $found );
5714+
}
5715+
5716+
/**
5717+
* @ticket 65313
5718+
*
5719+
* @covers WP_Comment_Query::query
5720+
*/
5721+
public function test_fields_unknown_column_falls_back_to_full_objects() {
5722+
self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
5723+
5724+
$q = new WP_Comment_Query();
5725+
$found = $q->query( array( 'fields' => 'not_a_real_column' ) );
5726+
5727+
$this->assertNotEmpty( $found );
5728+
$this->assertInstanceOf( 'WP_Comment', $found[0] );
5729+
}
5730+
5731+
/**
5732+
* @ticket 65313
5733+
*
5734+
* @covers WP_Comment_Query::query
5735+
*/
5736+
public function test_fields_ids_behavior_is_unchanged() {
5737+
$c1 = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
5738+
$c2 = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
5739+
5740+
$q = new WP_Comment_Query();
5741+
$found = $q->query( array( 'fields' => 'ids' ) );
5742+
5743+
$this->assertSameSets( array( $c1, $c2 ), $found );
5744+
// Legacy contract: comment IDs are returned as integers, not strings.
5745+
foreach ( $found as $id ) {
5746+
$this->assertIsInt( $id );
5747+
}
5748+
}
5749+
5750+
/**
5751+
* @ticket 65313
5752+
*
5753+
* @covers WP_Comment_Query::query
5754+
*/
5755+
public function test_fields_empty_result_set_returns_empty_array() {
5756+
$q = new WP_Comment_Query();
5757+
$found = $q->query(
5758+
array(
5759+
'post_id' => PHP_INT_MAX,
5760+
'fields' => 'comment_post_ID',
5761+
)
5762+
);
5763+
5764+
$this->assertSame( array(), $found );
5765+
}
55375766
}

0 commit comments

Comments
 (0)