From e1b44f52034a8c062cf0193cef77d255189782e1 Mon Sep 17 00:00:00 2001 From: Boone Gorges Date: Fri, 25 Sep 2015 15:12:09 +0000 Subject: [PATCH] Introduce hierarchical query support to `WP_Comment_Query`. Comments can be threaded. Now your query can be threaded too! Bonus: it's not totally insane. * The new `$hierarchical` parameter for `WP_Comment_Query` accepts three values: * `false` - Default value, and equivalent to current behavior. No descendants are fetched for matched comments. * `'flat'` - `WP_Comment_Query` will fetch the descendant tree for each comment matched by the query paramaters, and append them to the flat array of comments returned. Use this when you have a separate routine for constructing the tree - for example, when passing a list of comments to a `Walker` object. * `'threaded'` - `WP_Comment_Query` will fetch the descendant tree for each comment, and return it in a tree structure located in the `children` property of the `WP_Comment` objects. * `WP_Comment` now has a few utility methods for fetching the descendant tree (`get_children()`), fetching a single direct descendant comment (`get_child()`), and adding anothing `WP_Comment` object as a direct descendant (`add_child()`). Note that `add_child()` only modifies the comment object - it does not touch the database. Props boonebgorges, wonderboymusic. See #8071. git-svn-id: https://develop.svn.wordpress.org/trunk@34546 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-comment-query.php | 97 +++++++++++++++- src/wp-includes/class-wp-comment.php | 59 ++++++++++ tests/phpunit/tests/comment.php | 51 +++++++++ tests/phpunit/tests/comment/query.php | 122 +++++++++++++++++++++ 4 files changed, 328 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index e9a5ec60c8..67098904d1 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -137,7 +137,8 @@ class WP_Comment_Query { * * @since 4.2.0 * @since 4.4.0 `$parent__in` and `$parent__not_in` were added. - * @since 4.4.0 Order by `comment__in` was added. `$update_comment_meta_cache` and `$no_found_rows` were added. + * @since 4.4.0 Order by `comment__in` was added. `$update_comment_meta_cache`, `$no_found_rows`, + * and `$hierarchical` were added. * @access public * * @param string|array $query { @@ -206,6 +207,13 @@ class WP_Comment_Query { * @type array $type__in Include comments from a given array of comment types. Default empty. * @type array $type__not_in Exclude comments from a given array of comment types. Default empty. * @type int $user_id Include comments for a specific user ID. Default empty. + * @type bool|string $hierarchical Whether to include comment descendants in the results. + * 'threaded' returns a tree, with each comment's children stored + * in a `children` property on the `WP_Comment` object. 'flat' + * returns a flat array of found comments plus their children. + * Pass `false` to leave out descendants. The parameter is ignored + * (forced to `false`) when `$fields` is 'ids' or 'counts'. + * Accepts 'threaded', 'flat', or false. Default: false. * @type bool $update_comment_meta_cache Whether to prime the metadata cache for found comments. * Default true. * } @@ -249,6 +257,7 @@ class WP_Comment_Query { 'meta_value' => '', 'meta_query' => '', 'date_query' => null, // See WP_Date_Query + 'hierarchical' => false, 'update_comment_meta_cache' => true, ); @@ -396,6 +405,10 @@ class WP_Comment_Query { // Convert to WP_Comment instances $comments = array_map( 'get_comment', $_comments ); + if ( $this->query_vars['hierarchical'] ) { + $comments = $this->fill_descendants( $comments ); + } + $this->comments = $comments; return $this->comments; } @@ -665,6 +678,10 @@ class WP_Comment_Query { } } + if ( $this->query_vars['hierarchical'] && ! $this->query_vars['parent'] ) { + $this->query_vars['parent'] = 0; + } + if ( '' !== $this->query_vars['parent'] ) { $this->sql_clauses['where']['parent'] = $wpdb->prepare( 'comment_parent = %d', $this->query_vars['parent'] ); } @@ -797,6 +814,84 @@ class WP_Comment_Query { } } + /** + * Fetch descendants for located comments. + * + * Instead of calling `get_children()` separately on each child comment, we do a single set of queries to fetch + * the descendant trees for all matched top-level comments. + * + * @since 4.4.0 + * + * @param array $comments Array of top-level comments whose descendants should be filled in. + * @return array + */ + protected function fill_descendants( $comments ) { + global $wpdb; + + $levels = array( + 0 => wp_list_pluck( $comments, 'comment_ID' ), + ); + + $where_clauses = $this->sql_clauses['where']; + unset( + $where_clauses['parent'], + $where_clauses['parent__in'], + $where_clauses['parent__not_in'] + ); + + // Fetch an entire level of the descendant tree at a time. + $level = 0; + do { + $parent_ids = $levels[ $level ]; + $where = 'WHERE ' . implode( ' AND ', $where_clauses ) . ' AND comment_parent IN (' . implode( ',', array_map( 'intval', $parent_ids ) ) . ')'; + $comment_ids = $wpdb->get_col( "{$this->sql_clauses['select']} {$this->sql_clauses['from']} {$where} {$this->sql_clauses['groupby']}" ); + + $level++; + $levels[ $level ] = $comment_ids; + } while ( $comment_ids ); + + // Prime comment caches for non-top-level comments. + $descendant_ids = array(); + for ( $i = 1; $i < count( $levels ); $i++ ) { + $descendant_ids = array_merge( $descendant_ids, $levels[ $i ] ); + } + + _prime_comment_caches( $descendant_ids, $this->query_vars['update_comment_meta_cache'] ); + + // Assemble a flat array of all comments + descendants. + $all_comments = $comments; + foreach ( $descendant_ids as $descendant_id ) { + $all_comments[] = get_comment( $descendant_id ); + } + + // If a threaded representation was requested, build the tree. + if ( 'threaded' === $this->query_vars['hierarchical'] ) { + $threaded_comments = $ref = array(); + foreach ( $all_comments as $k => $c ) { + $_c = get_comment( $c->comment_ID ); + + // If the comment isn't in the reference array, it goes in the top level of the thread. + if ( ! isset( $ref[ $c->comment_parent ] ) ) { + $threaded_comments[ $_c->comment_ID ] = $_c; + $ref[ $_c->comment_ID ] = $threaded_comments[ $_c->comment_ID ]; + + // Otherwise, set it as a child of its parent. + } else { + + $ref[ $_c->comment_parent ]->add_child( $_c ); +// $ref[ $c->comment_parent ]->children[ $c->comment_ID ] = $c; + $ref[ $_c->comment_ID ] = $ref[ $_c->comment_parent ]->get_child( $_c->comment_ID ); + } + } + + $comments = $threaded_comments; + } else { + $comments = $all_comments; + } + + return $comments; + } + /** * Used internally to generate an SQL string for searching across multiple columns * diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 581fca2db2..955925b1b8 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -149,6 +149,15 @@ final class WP_Comment { */ public $user_id = 0; + /** + * Comment children. + * + * @since 4.4.0 + * @access protected + * @var array + */ + protected $children; + /** * Retrieves a WP_Comment instance. * @@ -211,4 +220,54 @@ final class WP_Comment { public function to_array() { return get_object_vars( $this ); } + + /** + * Get the children of a comment. + * + * @since 4.4.0 + * @access public + * + * @return array Array of `WP_Comment` objects. + */ + public function get_children() { + if ( is_null( $this->children ) ) { + $this->children = get_comments( array( + 'parent' => $this->comment_ID, + 'hierarchical' => 'threaded', + ) ); + } + + return $this->children; + } + + /** + * Add a child to the comment. + * + * Used by `WP_Comment_Query` when bulk-filling descendants. + * + * @since 4.4.0 + * @access public + * + * @param WP_Comment $child Child comment. + */ + public function add_child( WP_Comment $child ) { + $this->comments[ $child->comment_ID ] = $child; + } + + /** + * Get a child comment by ID. + * + * @since 4.4.0 + * @access public + * + * @param int $child_id ID of the child. + * @return WP_Comment|bool Returns the comment object if found, otherwise false. + */ + public function get_child( $child_id ) { + if ( isset( $this->comments[ $child_id ] ) ) { + return $this->comments[ $child_id ]; + } + + return false; + } } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 4c69ccac49..07c02c3bda 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -288,4 +288,55 @@ class Tests_Comment extends WP_UnitTestCase { $this->assertEquals( 'fire', get_comment_meta( $c, 'sauce', true ) ); } + + /** + * @ticket 8071 + */ + public function test_wp_comment_get_children_should_fill_children() { + + $p = $this->factory->post->create(); + + $c1 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + ) ); + + $c2 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + 'comment_parent' => $c1, + ) ); + + $c3 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + 'comment_parent' => $c2, + ) ); + + $c4 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + 'comment_parent' => $c1, + ) ); + + $c5 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + ) ); + + $c6 = $this->factory->comment->create( array( + 'comment_post_ID' => $p, + 'comment_approved' => '1', + 'comment_parent' => $c5, + ) ); + + $comment = get_comment( $c1 ); + $children = $comment->get_children(); + + // Direct descendants of $c1. + $this->assertEquals( array( $c2, $c4 ), array_values( wp_list_pluck( $children, 'comment_ID' ) ) ); + + // Direct descendants of $c2. + $this->assertEquals( array( $c3 ), array_values( wp_list_pluck( $children[ $c2 ]->get_children(), 'comment_ID' ) ) ); + } } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index ca6464c7db..69764336fc 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -1920,4 +1920,126 @@ class Tests_Comment_Query extends WP_UnitTestCase { $this->assertEquals( 3, $q->found_comments ); $this->assertEquals( 2, $q->max_num_pages ); } + + /** + * @ticket 8071 + */ + public function test_hierarchical_should_skip_child_comments_in_offset() { + $top_level_0 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $child_of_0 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $top_level_0, + ) ); + + $top_level_comments = $this->factory->comment->create_many( 3, array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $q = new WP_Comment_Query( array( + 'post_id' => $this->post_id, + 'hierarchical' => 'flat', + 'number' => 2, + 'offset' => 1, + 'orderby' => 'comment_ID', + 'order' => 'ASC', + 'fields' => 'ids', + ) ); + + $this->assertEquals( array( $top_level_comments[0], $top_level_comments[1] ), $q->comments ); + } + + /** + * @ticket 8071 + */ + public function test_hierarchical_should_not_include_child_comments_in_number() { + $top_level_0 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $child_of_0 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $top_level_0, + ) ); + + $top_level_comments = $this->factory->comment->create_many( 3, array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $q = new WP_Comment_Query( array( + 'post_id' => $this->post_id, + 'hierarchical' => 'flat', + 'number' => 2, + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) ); + + $this->assertEqualSets( array( $top_level_0, $child_of_0, $top_level_comments[0] ), wp_list_pluck( $q->comments, 'comment_ID' ) ); + } + + /** + * @ticket 8071 + */ + public function test_hierarchical_threaded() { + $c1 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $c2 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $c1, + ) ); + + $c3 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $c2, + ) ); + + $c4 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $c1, + ) ); + + $c5 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + ) ); + + $c6 = $this->factory->comment->create( array( + 'comment_post_ID' => $this->post_id, + 'comment_approved' => '1', + 'comment_parent' => $c5, + ) ); + + $q = new WP_Comment_Query( array( + 'post_id' => $this->post_id, + 'hierarchical' => 'threaded', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) ); + + // Top-level comments. + $this->assertEquals( array( $c1, $c5 ), array_values( wp_list_pluck( $q->comments, 'comment_ID' ) ) ); + + // Direct descendants of $c1. + $this->assertEquals( array( $c2, $c4 ), array_values( wp_list_pluck( $q->comments[ $c1 ]->get_children(), 'comment_ID' ) ) ); + + // Direct descendants of $c2. + $this->assertEquals( array( $c3 ), array_values( wp_list_pluck( $q->comments[ $c1 ]->get_child( $c2 )->get_children(), 'comment_ID' ) ) ); + + // Direct descendants of $c5. + $this->assertEquals( array( $c6 ), array_values( wp_list_pluck( $q->comments[ $c5 ]->get_children(), 'comment_ID' ) ) ); + } }