From ffad749d2af7c79886534750a1c42ff29d784f52 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Mon, 17 Aug 2026 12:27:34 +0200 Subject: [PATCH 1/8] Improve selective fields performance --- ...lass-bp-rest-activity-comment-endpoint.php | 14 +- .../class-bp-rest-activity-endpoint.php | 385 ++++++++---- .../testcases/activity/rest-fields.php | 577 ++++++++++++++++++ 3 files changed, 845 insertions(+), 131 deletions(-) create mode 100644 tests/phpunit/testcases/activity/rest-fields.php diff --git a/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php index 65bbdc54ec1..b40e6f9ee3b 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php @@ -1027,6 +1027,18 @@ protected function prepare_activity_comments( $comments, $request ) { $comment_load_limit = bb_get_activity_comment_loading(); } + /* + * Activity comments are built by the activity controller, but this one + * answers with a payload of its own: `get_items()`, `create_item()` and + * `delete_item()` nest the comments inside an envelope, so the caller's + * `_fields` addresses that envelope and must not narrow the comments. + * Stripping it here covers every caller. `get_item()` and + * `update_item()` do return a bare comment and could honour a + * selection, but they prepare a single row, so they forgo that saving + * rather than leave a future caller free to forget the strip. + */ + $comment_request = $this->activity_endpoint->bb_rest_request_without_fields( $request ); + $comment_loaded_count = 0; foreach ( $comments as $comment ) { @@ -1040,7 +1052,7 @@ protected function prepare_activity_comments( $comments, $request ) { } $data[] = $this->activity_endpoint->prepare_response_for_collection( - $this->activity_endpoint->prepare_item_for_response( $comment, $request ) + $this->activity_endpoint->prepare_item_for_response( $comment, $comment_request ) ); $comment_loaded_count++; diff --git a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php index b98a98d6719..d2e968a6237 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php @@ -1399,7 +1399,7 @@ public function delete_item( $request ) { // Get the activity before it's deleted. $activity = $this->get_activity_object( $request ); - $previous = $this->prepare_item_for_response( $activity, $request ); + $previous = $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ); if ( 'activity_comment' === $activity->type ) { $retval = bp_activity_delete_comment( $activity->item_id, $activity->id ); @@ -1752,7 +1752,7 @@ public function update_pin( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $request ) + $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) ); $retval = array( @@ -1919,7 +1919,7 @@ public function update_close_comments( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $request ) + $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) ); $retval = array( @@ -2146,52 +2146,113 @@ function_exists( 'bp_is_activity_edit_enabled' ) $date_recorded = bp_rest_prepare_date_response( $activity->date_recorded ); } - $data = array( - 'user_id' => $activity->user_id, - 'name' => bp_core_get_user_displayname( $activity->user_id ), - 'component' => $activity->component, - 'post_title' => ! empty( $activity->post_title ) ? html_entity_decode( esc_html( $activity->post_title ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) : '', - 'content' => array( + /* + * The fields the request asked for. When the request carries no + * `_fields`, this is every property of the item schema, so each of the + * branches below runs exactly as it did before the controller became + * field-aware. + */ + $fields = $this->get_fields_for_response( $request ); + + /* + * The embed resolution further down inspects the rendered content, so + * it has to be produced whenever either of them belongs in the response. + */ + $include_content = rest_is_field_included( 'content', $fields ); + $include_embed_data = rest_is_field_included( 'preview_data', $fields ) || rest_is_field_included( 'link_embed_url', $fields ); + + $data = array(); + + $data['user_id'] = $activity->user_id; + + if ( rest_is_field_included( 'name', $fields ) ) { + $data['name'] = bp_core_get_user_displayname( $activity->user_id ); + } + + $data['component'] = $activity->component; + $data['post_title'] = ! empty( $activity->post_title ) ? html_entity_decode( esc_html( $activity->post_title ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) : ''; + + $rendered_content = ( $include_content || $include_embed_data ) ? $this->render_item( $activity ) : ''; + + if ( $include_content ) { + $data['content'] = array( 'raw' => bb_rest_raw_content( $activity->content ), - 'rendered' => $this->render_item( $activity ), - ), - 'date' => $date_recorded, - 'id' => $activity->id, - 'link' => bp_activity_get_permalink( $activity->id ), - 'primary_item_id' => $activity->item_id, - 'secondary_item_id' => $activity->secondary_item_id, - 'status' => $activity->is_spam ? 'spam' : $activity->status, - 'title' => $this->bb_rest_activity_action( $activity->action, $activity ), - 'type' => $activity->type, - 'favorited' => in_array( $activity->id, $this->get_user_favorites( $activity ), true ), - - // extend response. - 'can_favorite' => ( 'activity_comment' === $activity->type ) ? bb_activity_comment_can_favorite() : bp_activity_can_favorite(), - 'favorite_count' => $this->get_activity_favorite_count( $activity ), - 'can_comment' => ( 'activity_comment' === $activity->type ) ? bp_activity_can_comment_reply( $activity ) : bp_activity_can_comment(), - 'can_edit' => $can_edit, - 'is_edited' => $activity_metas['_is_edited'][0] ?? '', - 'can_delete' => bp_activity_user_can_delete( $activity ), - 'content_stripped' => html_entity_decode( wp_strip_all_tags( $activity->content ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ), - 'privacy' => ( isset( $activity->privacy ) ? $activity->privacy : false ), - 'activity_data' => $this->bp_rest_activitiy_edit_data( $activity ), - 'feature_media' => '', - 'preview_data' => '', - 'link_embed_url' => '', - 'is_pinned' => false, - 'can_pin' => false, - 'reacted_names' => function_exists( 'bb_activity_reaction_names_and_count' ) ? bb_activity_reaction_names_and_count( $activity->id, 'activity_comment' === $activity->type ? $activity->type : 'activity', 1 ) : '', - 'reacted_counts' => function_exists( 'bb_get_activity_most_reactions' ) ? bb_get_activity_most_reactions( $activity->id, 'activity_comment' === $activity->type ? $activity->type : 'activity', 7 ) : array(), - 'reacted_id' => ( function_exists( 'bb_load_reaction' ) && bb_load_reaction() ) ? bb_load_reaction()->bb_user_reacted_reaction_id( + 'rendered' => $rendered_content, + ); + } + + $data['date'] = $date_recorded; + $data['id'] = $activity->id; + + if ( rest_is_field_included( 'link', $fields ) ) { + $data['link'] = bp_activity_get_permalink( $activity->id ); + } + + $data['primary_item_id'] = $activity->item_id; + $data['secondary_item_id'] = $activity->secondary_item_id; + $data['status'] = $activity->is_spam ? 'spam' : $activity->status; + $data['title'] = $this->bb_rest_activity_action( $activity->action, $activity ); + $data['type'] = $activity->type; + + if ( rest_is_field_included( 'favorited', $fields ) ) { + $data['favorited'] = in_array( $activity->id, $this->get_user_favorites( $activity ), true ); + } + + // extend response. + $data['can_favorite'] = ( 'activity_comment' === $activity->type ) ? bb_activity_comment_can_favorite() : bp_activity_can_favorite(); + + if ( rest_is_field_included( 'favorite_count', $fields ) ) { + $data['favorite_count'] = $this->get_activity_favorite_count( $activity ); + } + + $data['can_comment'] = ( 'activity_comment' === $activity->type ) ? bp_activity_can_comment_reply( $activity ) : bp_activity_can_comment(); + $data['can_edit'] = $can_edit; + $data['is_edited'] = $activity_metas['_is_edited'][0] ?? ''; + $data['can_delete'] = bp_activity_user_can_delete( $activity ); + $data['content_stripped'] = html_entity_decode( wp_strip_all_tags( $activity->content ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ); + $data['privacy'] = ( isset( $activity->privacy ) ? $activity->privacy : false ); + + if ( rest_is_field_included( 'activity_data', $fields ) ) { + $data['activity_data'] = $this->bp_rest_activitiy_edit_data( $activity ); + } + + if ( rest_is_field_included( 'feature_media', $fields ) ) { + $data['feature_media'] = ''; + } + + if ( $include_embed_data ) { + $data['preview_data'] = ''; + $data['link_embed_url'] = ''; + } + + if ( rest_is_field_included( 'is_pinned', $fields ) ) { + $data['is_pinned'] = false; + } + + if ( rest_is_field_included( 'can_pin', $fields ) ) { + $data['can_pin'] = false; + } + + if ( rest_is_field_included( 'reacted_names', $fields ) ) { + $data['reacted_names'] = function_exists( 'bb_activity_reaction_names_and_count' ) ? bb_activity_reaction_names_and_count( $activity->id, 'activity_comment' === $activity->type ? $activity->type : 'activity', 1 ) : ''; + } + + if ( rest_is_field_included( 'reacted_counts', $fields ) ) { + $data['reacted_counts'] = function_exists( 'bb_get_activity_most_reactions' ) ? bb_get_activity_most_reactions( $activity->id, 'activity_comment' === $activity->type ? $activity->type : 'activity', 7 ) : array(); + } + + if ( rest_is_field_included( 'reacted_id', $fields ) ) { + $data['reacted_id'] = ( function_exists( 'bb_load_reaction' ) && bb_load_reaction() ) ? bb_load_reaction()->bb_user_reacted_reaction_id( array( 'item_id' => $activity->id, 'item_type' => 'activity_comment' === $activity->type ? $activity->type : 'activity', 'user_id' => bp_loggedin_user_id(), ) - ) : 0, - 'is_comment_closed' => function_exists( 'bb_is_close_activity_comments_enabled' ) && bb_is_close_activity_comments_enabled() ? bb_is_activity_comments_closed( $activity->id ) : false, - 'activity_status' => $activity->status, - ); + ) : 0; + } + + $data['is_comment_closed'] = function_exists( 'bb_is_close_activity_comments_enabled' ) && bb_is_close_activity_comments_enabled() ? bb_is_activity_comments_closed( $activity->id ) : false; + $data['activity_status'] = $activity->status; $data['bb_activity_post_feature_image'] = array(); if ( ! empty( $activity->id ) ) { @@ -2204,58 +2265,60 @@ function_exists( 'bp_is_activity_edit_enabled' ) } // Add feature image as separate object which added last in the content. - if ( ! empty( $blog_id ) && ! empty( get_post_thumbnail_id( $blog_id ) ) ) { + if ( rest_is_field_included( 'feature_media', $fields ) && ! empty( $blog_id ) && ! empty( get_post_thumbnail_id( $blog_id ) ) ) { $data['feature_media'] = wp_get_attachment_image_url( get_post_thumbnail_id( $blog_id ), 'full' ); } - // Add iframe embedded data in separate object. - $link_embed = $activity_metas['_link_embed'][0] ?? ''; + if ( $include_embed_data ) { + // Add iframe embedded data in separate object. + $link_embed = $activity_metas['_link_embed'][0] ?? ''; - if ( ! empty( $link_embed ) ) { - $data['link_embed_url'] = $link_embed; - } + if ( ! empty( $link_embed ) ) { + $data['link_embed_url'] = $link_embed; + } - if ( ! empty( $link_embed ) && method_exists( $bp->embed, 'autoembed' ) ) { - $data['preview_data'] = $bp->embed->autoembed( '', $activity ); + if ( ! empty( $link_embed ) && method_exists( $bp->embed, 'autoembed' ) ) { + $data['preview_data'] = $bp->embed->autoembed( '', $activity ); - // Removed lazyload from link preview. - $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, true ); - } elseif ( method_exists( $bp->embed, 'autoembed' ) && ! empty( $data['content_stripped'] ) ) { - $skip_embed = false; - if ( ! empty( $data['content']['rendered'] ) ) { - - // Check if already embed in rendered content. - preg_match( '/]*><\/iframe>/', $data['content']['rendered'], $matchcontent ); - if ( ! empty( $matchcontent[0] ) ) { - $skip_embed = true; + // Removed lazyload from link preview. + $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, true ); + } elseif ( method_exists( $bp->embed, 'autoembed' ) && ! empty( $data['content_stripped'] ) ) { + $skip_embed = false; + if ( ! empty( $rendered_content ) ) { + + // Check if already embed in rendered content. + preg_match( '/]*><\/iframe>/', $rendered_content, $matchcontent ); + if ( ! empty( $matchcontent[0] ) ) { + $skip_embed = true; + } } - } - if ( ! $skip_embed ) { - $check_embedded_content = $bp->embed->autoembed( $data['content_stripped'], $activity ); - if ( ! empty( $check_embedded_content ) ) { - preg_match( '/]*><\/iframe>/', $check_embedded_content, $match ); - if ( ! empty( $match[0] ) ) { - $data['preview_data'] = $match[0]; - // Use a regular expression to find the src URL. - preg_match( '/src="([^"]+)"/', $match[0], $matches ); - if ( ! empty( $matches[1] ) ) { - - // Set link_embed_url with the iframe src URL as a fallback. - $data['link_embed_url'] = $matches[1]; + if ( ! $skip_embed ) { + $check_embedded_content = $bp->embed->autoembed( $data['content_stripped'], $activity ); + if ( ! empty( $check_embedded_content ) ) { + preg_match( '/]*><\/iframe>/', $check_embedded_content, $match ); + if ( ! empty( $match[0] ) ) { + $data['preview_data'] = $match[0]; + // Use a regular expression to find the src URL. + preg_match( '/src="([^"]+)"/', $match[0], $matches ); + if ( ! empty( $matches[1] ) ) { + + // Set link_embed_url with the iframe src URL as a fallback. + $data['link_embed_url'] = $matches[1]; + } } } + // Removed lazyload from link preview. + $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, true ); } - // Removed lazyload from link preview. - $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, true ); } - } - // Add link preview data in separate object. - $link_preview = bp_activity_link_preview( '', $activity ); - if ( ! empty( $link_preview ) ) { - $data['preview_data'] = $link_preview; - } elseif ( empty( $link_preview ) && in_array( $activity->type, array( 'bbp_reply_create', 'bbp_topic_create' ), true ) ) { - $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, empty( $data['preview_data'] ) ); + // Add link preview data in separate object. + $link_preview = bp_activity_link_preview( '', $activity ); + if ( ! empty( $link_preview ) ) { + $data['preview_data'] = $link_preview; + } elseif ( empty( $link_preview ) && in_array( $activity->type, array( 'bbp_reply_create', 'bbp_topic_create' ), true ) ) { + $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, empty( $data['preview_data'] ) ); + } } // remove comment options from media/document/video activity. @@ -2286,21 +2349,24 @@ function_exists( 'bp_is_activity_edit_enabled' ) } } - $pinned_id = 0; + if ( rest_is_field_included( 'is_pinned', $fields ) ) { + $pinned_id = 0; - if ( 'groups' === $activity->component ) { - $pinned_id = groups_get_groupmeta( $activity->item_id, 'bb_pinned_post' ); - } else { - $pinned_id = bp_get_option( 'bb_pinned_post', 0 ); - } + if ( 'groups' === $activity->component ) { + $pinned_id = groups_get_groupmeta( $activity->item_id, 'bb_pinned_post' ); + } else { + $pinned_id = bp_get_option( 'bb_pinned_post', 0 ); + } - // Pinned post. - if ( ! empty( $pinned_id ) && (int) $pinned_id === (int) $activity->id ) { - $data['is_pinned'] = true; + // Pinned post. + if ( ! empty( $pinned_id ) && (int) $pinned_id === (int) $activity->id ) { + $data['is_pinned'] = true; + } } // Show pin actions. if ( + rest_is_field_included( 'can_pin', $fields ) && 'activity_comment' !== $activity->type && ! in_array( $activity->privacy, array( 'media', 'document', 'video' ), true ) && ( @@ -2336,7 +2402,12 @@ function_exists( 'bp_is_activity_edit_enabled' ) $data['can_pin'] = true; } - $data['can_close_comment'] = false; + $include_can_close_comment = rest_is_field_included( 'can_close_comment', $fields ); + + if ( $include_can_close_comment ) { + $data['can_close_comment'] = false; + } + if ( function_exists( 'bb_is_close_activity_comments_enabled' ) && bb_is_close_activity_comments_enabled() ) { if ( $data['is_comment_closed'] ) { @@ -2344,14 +2415,16 @@ function_exists( 'bp_is_activity_edit_enabled' ) } // Closed comments actions allowed or not. - $check_args = array( - 'activity_id' => $activity->id, - 'action' => $data['is_comment_closed'] ? 'unclose_comments' : 'close_comments', - ); + if ( $include_can_close_comment ) { + $check_args = array( + 'activity_id' => $activity->id, + 'action' => $data['is_comment_closed'] ? 'unclose_comments' : 'close_comments', + ); - $retval = bb_activity_comments_close_action_allowed( $check_args ); - if ( 'allowed' === $retval ) { - $data['can_close_comment'] = true; + $retval = bb_activity_comments_close_action_allowed( $check_args ); + if ( 'allowed' === $retval ) { + $data['can_close_comment'] = true; + } } } @@ -2364,30 +2437,47 @@ function_exists( 'bp_is_activity_edit_enabled' ) } // Commenter's mention name and profile URL to build the auto-mention when replying. - $data['mention_name'] = function_exists( 'bp_activity_get_user_mentionname' ) && ! empty( $activity->user_id ) ? bp_activity_get_user_mentionname( $activity->user_id ) : ''; - $data['user_link'] = function_exists( 'bp_core_get_user_domain' ) && ! empty( $activity->user_id ) ? bp_core_get_user_domain( $activity->user_id ) : ''; - - // Get comments (count). - if ( ! empty( $activity->children ) ) { - $data['comment_count'] = isset( $activity->all_child_count ) ? $activity->all_child_count : bp_activity_recurse_comment_count( $activity ); - if ( ! empty( $schema['properties']['comments'] ) && 'threaded' === $request['display_comments'] && empty( $request->get_param( 'apply_limit' ) ) ) { - // First check the comment is disabled from the activity settings for post type. - // For more information, please check this PROD-2475. - if ( 'blogs' === $activity->component && $data['can_comment'] ) { - $data['comments'] = $this->prepare_activity_comments( $activity->children, $request ); - // This is for activity comment to attach the comment in the feed. - } elseif ( 'blogs' !== $activity->component ) { - $data['comments'] = $this->prepare_activity_comments( $activity->children, $request ); + if ( rest_is_field_included( 'mention_name', $fields ) ) { + $data['mention_name'] = function_exists( 'bp_activity_get_user_mentionname' ) && ! empty( $activity->user_id ) ? bp_activity_get_user_mentionname( $activity->user_id ) : ''; + } + + if ( rest_is_field_included( 'user_link', $fields ) ) { + $data['user_link'] = function_exists( 'bp_core_get_user_domain' ) && ! empty( $activity->user_id ) ? bp_core_get_user_domain( $activity->user_id ) : ''; + } + + $include_comments = rest_is_field_included( 'comments', $fields ); + + $include_notification = ! empty( $schema['properties']['is_receive_notification'] ) && ( + rest_is_field_included( 'is_receive_notification', $fields ) || + rest_is_field_included( 'can_toggle_notification', $fields ) + ); + + /* + * Get comments (count). The notification block further down reads the + * comment children this populates, so it runs for those fields too. + */ + if ( rest_is_field_included( 'comment_count', $fields ) || $include_comments || $include_notification ) { + if ( ! empty( $activity->children ) ) { + $data['comment_count'] = isset( $activity->all_child_count ) ? $activity->all_child_count : bp_activity_recurse_comment_count( $activity ); + if ( $include_comments && ! empty( $schema['properties']['comments'] ) && 'threaded' === $request['display_comments'] && empty( $request->get_param( 'apply_limit' ) ) ) { + // First check the comment is disabled from the activity settings for post type. + // For more information, please check this PROD-2475. + if ( 'blogs' === $activity->component && $data['can_comment'] ) { + $data['comments'] = $this->prepare_activity_comments( $activity->children, $request ); + // This is for activity comment to attach the comment in the feed. + } elseif ( 'blogs' !== $activity->component ) { + $data['comments'] = $this->prepare_activity_comments( $activity->children, $request ); + } } + } elseif ( isset( $activity->all_child_count ) ) { + $data['comment_count'] = $activity->all_child_count; + } else { + $activity->children = BP_Activity_Activity::get_activity_comments( $activity->id, $activity->mptt_left, $activity->mptt_right, $request['status'], $top_level_parent_id, true ); + $data['comment_count'] = ! empty( $activity->children ) ? bp_activity_recurse_comment_count( $activity ) : 0; } - } elseif ( isset( $activity->all_child_count ) ) { - $data['comment_count'] = $activity->all_child_count; - } else { - $activity->children = BP_Activity_Activity::get_activity_comments( $activity->id, $activity->mptt_left, $activity->mptt_right, $request['status'], $top_level_parent_id, true ); - $data['comment_count'] = ! empty( $activity->children ) ? bp_activity_recurse_comment_count( $activity ) : 0; } - if ( ! empty( $schema['properties']['user_avatar'] ) ) { + if ( ! empty( $schema['properties']['user_avatar'] ) && rest_is_field_included( 'user_avatar', $fields ) ) { $data['user_avatar'] = array( 'full' => bp_core_fetch_avatar( array( @@ -2406,7 +2496,7 @@ function_exists( 'bp_is_activity_edit_enabled' ) } // Turn On/Off notification. - if ( ! empty( $schema['properties']['is_receive_notification'] ) ) { + if ( $include_notification ) { $data['can_toggle_notification'] = false; $notification_type = bb_activity_enabled_notification( 'bb_activity_comment', bp_loggedin_user_id() ); $user_ids = ! empty( $activity->children ) @@ -2449,6 +2539,30 @@ function_exists( 'bp_is_activity_edit_enabled' ) return apply_filters( 'bp_rest_activity_prepare_value', $response, $request, $activity ); } + /** + * Clone a request with its `_fields` selection removed. + * + * A prepared activity is sometimes nested inside a response that is not an + * activity: the `previous` key of a delete, the `activity` key of a pin, + * close-comments or mute action, the `comments` list of its parent, or the + * payload of the activity comment controller. In each of those cases the + * caller's `_fields` names the keys of the outer response, so it must not + * narrow the nested activity — WordPress returns such nested items whole. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Request Cloned request without the `_fields` parameter. + */ + public function bb_rest_request_without_fields( $request ) { + $request = clone $request; + + unset( $request['_fields'] ); + + return $request; + } + /** * Prepare activity comments. * @@ -2465,9 +2579,11 @@ protected function prepare_activity_comments( $comments, $request ) { return $data; } + $comment_request = $this->bb_rest_request_without_fields( $request ); + foreach ( $comments as $comment ) { $data[] = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $comment, $request ) + $this->prepare_item_for_response( $comment, $comment_request ) ); } @@ -2879,10 +2995,21 @@ public function get_favorite_endpoint_schema() { /** * Get the plugin schema, conforming to JSON Schema. * - * @return array * @since 0.1.0 + * @since BuddyBoss [BBVERSION] The schema is built once per request and reused. + * + * @return array */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the activity schema. + * + * @param string $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_activity_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_activity', @@ -3205,12 +3332,10 @@ public function get_item_schema() { ); } - /** - * Filters the activity schema. - * - * @param string $schema The endpoint schema. - */ - return apply_filters( 'bp_rest_activity_schema', $this->add_additional_fields_schema( $schema ) ); + $this->schema = $schema; + + /** This filter is documented in bp-activity/classes/class-bp-rest-activity-endpoint.php */ + return apply_filters( 'bp_rest_activity_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** @@ -3747,7 +3872,7 @@ public function update_mute_unmute_notification( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $request ) + $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) ); $retval = array( diff --git a/tests/phpunit/testcases/activity/rest-fields.php b/tests/phpunit/testcases/activity/rest-fields.php new file mode 100644 index 00000000000..8ca0f94299a --- /dev/null +++ b/tests/phpunit/testcases/activity/rest-fields.php @@ -0,0 +1,577 @@ +plugin_dir . 'bp-core/admin/bp-core-admin-schema.php'; + bp_core_install_emails(); + } + + /* + * The test case restores the hook snapshot after every test, which + * unregisters the callbacks `rest_api_init` added -- including + * `rest_filter_response_fields()`. Rebuild the server per test so the + * dispatch pipeline is the one a real request goes through. + */ + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + + $this->server = $wp_rest_server; + $this->endpoint = new BP_REST_Activity_Endpoint(); + $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/activity'; + + $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + $this->activity_id = self::factory()->activity->create( + array( + 'user_id' => $this->user_id, + 'component' => 'activity', + 'type' => 'activity_update', + 'content' => 'Field selection fixture.', + ) + ); + + wp_set_current_user( $this->user_id ); + } + + /** + * Drop the REST server so the next test builds a fresh one. + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tearDown(); + } + + /** + * Fields whose construction is guarded by the `_fields` selection. + * + * `comments` is deliberately absent: it is gated on `display_comments` + * and is covered by its own test. + * + * @return array + */ + public static function guarded_field_provider() { + return array( + array( 'name' ), + array( 'mention_name' ), + array( 'user_link' ), + array( 'link' ), + array( 'content' ), + array( 'favorited' ), + array( 'favorite_count' ), + array( 'activity_data' ), + array( 'feature_media' ), + array( 'preview_data' ), + array( 'link_embed_url' ), + array( 'is_pinned' ), + array( 'can_pin' ), + array( 'reacted_names' ), + array( 'reacted_counts' ), + array( 'reacted_id' ), + array( 'can_close_comment' ), + array( 'comment_count' ), + array( 'user_avatar' ), + array( 'can_toggle_notification' ), + array( 'is_receive_notification' ), + ); + } + + /** + * Dispatch a request the way `WP_REST_Server::serve_request()` does. + * + * `rest_do_request()` skips `rest_post_dispatch`, which is where + * WordPress trims the response down to `_fields`. Without it these + * tests would not exercise the trimming at all. + * + * @param WP_REST_Request $request Request to dispatch. + * + * @return WP_REST_Response + */ + protected function dispatch( $request ) { + $response = $this->server->dispatch( $request ); + + return apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + } + + /** + * Fetch the activity collection and return the first item. + * + * @param array $params Request parameters. + * + * @return array + */ + protected function get_first_item( $params = array() ) { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + $data = $this->dispatch( $request )->get_data(); + + $this->assertNotEmpty( $data, 'The activity collection came back empty.' ); + + return $data[0]; + } + + /** + * Count the queries a single collection request costs, from a cold cache. + * + * @param array $params Request parameters. + * + * @return int + */ + protected function count_queries( $params = array() ) { + global $wpdb; + + wp_cache_flush(); + + $before = $wpdb->num_queries; + $this->get_first_item( $params ); + + return $wpdb->num_queries - $before; + } + + /** + * Count the queries a request sends to the reaction tables. + * + * @param array $params Request parameters. + * + * @return int + */ + protected function count_reaction_queries( $params = array() ) { + $count = 0; + + $counter = function ( $query ) use ( &$count ) { + if ( false !== strpos( $query, 'bb_user_reactions' ) || false !== strpos( $query, 'bb_reactions_data' ) ) { + ++$count; + } + + return $query; + }; + + wp_cache_flush(); + + add_filter( 'query', $counter ); + $this->get_first_item( $params ); + remove_filter( 'query', $counter ); + + return $count; + } + + /** + * A request that sends no `_fields` must behave exactly as it did before + * the controller became field-aware: every field is still built. + */ + public function test_no_field_selection_returns_every_field() { + $item = $this->get_first_item(); + $properties = $this->endpoint->get_item_schema(); + $properties = $properties['properties']; + + foreach ( self::guarded_field_provider() as $args ) { + $field = $args[0]; + + // Fields whose schema entry is registered conditionally are only + // expected when the feature behind them is active on this install. + if ( ! isset( $properties[ $field ] ) ) { + continue; + } + + $this->assertArrayHasKey( + $field, + $item, + sprintf( 'Field "%s" disappeared from an unfiltered response.', $field ) + ); + } + } + + /** + * A narrow selection returns exactly the requested keys. + */ + public function test_narrow_field_selection_returns_only_the_requested_keys() { + $item = $this->get_first_item( array( '_fields' => 'id,user_id,date' ) ); + + $actual = array_keys( $item ); + sort( $actual ); + + $this->assertSame( array( 'date', 'id', 'user_id' ), $actual ); + } + + /** + * A nested selection still builds the parent field. + * + * @dataProvider guarded_field_provider + * + * @param string $field Field name. + */ + public function test_guarded_field_is_returned_when_explicitly_selected( $field ) { + $properties = $this->endpoint->get_item_schema(); + $properties = $properties['properties']; + + if ( ! isset( $properties[ $field ] ) ) { + $this->markTestSkipped( sprintf( 'Field "%s" is not registered on this install.', $field ) ); + } + + $item = $this->get_first_item( array( '_fields' => 'id,' . $field ) ); + + $this->assertArrayHasKey( $field, $item ); + } + + /** + * `_fields=content.rendered` must still build `content`. + */ + public function test_nested_field_selection_still_builds_the_parent() { + $item = $this->get_first_item( array( '_fields' => 'id,content.rendered' ) ); + + $this->assertArrayHasKey( 'content', $item ); + $this->assertArrayHasKey( 'rendered', $item['content'] ); + $this->assertNotEmpty( $item['content']['rendered'] ); + + // WordPress trims the sibling away; the controller must not have to. + $this->assertArrayNotHasKey( 'raw', $item['content'] ); + } + + /** + * A selection combined with `_embed` still embeds. + */ + public function test_field_selection_with_embed_still_embeds() { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + $request->set_param( '_embed', true ); + $request->set_param( '_fields', 'id,user_id,_links,_embedded' ); + + $response = $this->dispatch( $request ); + $data = $this->server->response_to_data( $response, true ); + + $this->assertNotEmpty( $data ); + $this->assertArrayHasKey( '_links', $data[0] ); + $this->assertArrayHasKey( 'user', $data[0]['_links'] ); + $this->assertArrayHasKey( '_embedded', $data[0] ); + $this->assertArrayHasKey( 'user', $data[0]['_embedded'] ); + } + + /** + * `comments` is gated on `display_comments`, not on `_fields`, and must + * keep working when no selection is sent. + */ + public function test_threaded_comments_are_returned_without_a_field_selection() { + $this->create_comment(); + + $item = $this->get_first_item( + array( + 'display_comments' => 'threaded', + 'include' => $this->activity_id, + ) + ); + + $this->assertArrayHasKey( 'comments', $item ); + $this->assertNotEmpty( $item['comments'] ); + } + + /** + * Selecting `comments` must still return whole comment objects: WordPress + * does not trim inside a numerically indexed list, so the controller must + * not narrow the nested items either. + */ + public function test_selecting_comments_returns_whole_comment_objects() { + $this->create_comment(); + + $item = $this->get_first_item( + array( + 'display_comments' => 'threaded', + 'include' => $this->activity_id, + '_fields' => 'id,comments', + ) + ); + + $this->assertArrayHasKey( 'comments', $item ); + $this->assertNotEmpty( $item['comments'] ); + + $comment = $item['comments'][0]; + + $this->assertArrayHasKey( 'content', $comment ); + $this->assertArrayHasKey( 'user_id', $comment ); + $this->assertArrayHasKey( 'date', $comment ); + + // Fields added with `bp_rest_register_field()` belong to a nested + // comment too: `_fields` addresses the parent, never its comments. + $properties = $this->endpoint->get_item_schema(); + + if ( isset( $properties['properties']['can_report'] ) ) { + $this->assertArrayHasKey( 'can_report', $comment ); + } + } + + /** + * An unselected `content` must not be rendered. + */ + public function test_content_is_not_rendered_when_it_is_not_selected() { + $renders = 0; + $counter = function ( $content ) use ( &$renders ) { + $renders++; + + return $content; + }; + + add_filter( 'bp_get_activity_content_body', $counter ); + $this->get_first_item( array( '_fields' => 'id,user_id' ) ); + remove_filter( 'bp_get_activity_content_body', $counter ); + + $this->assertSame( 0, $renders ); + } + + /** + * ...but a selected `content` still is. + */ + public function test_content_is_rendered_when_it_is_selected() { + $renders = 0; + $counter = function ( $content ) use ( &$renders ) { + $renders++; + + return $content; + }; + + add_filter( 'bp_get_activity_content_body', $counter ); + $this->get_first_item( array( '_fields' => 'id,content' ) ); + remove_filter( 'bp_get_activity_content_body', $counter ); + + $this->assertGreaterThan( 0, $renders ); + } + + /** + * An unselected `activity_data` must not build the edit payload. + */ + public function test_activity_data_is_not_built_when_it_is_not_selected() { + $builds = 0; + $counter = function ( $data ) use ( &$builds ) { + $builds++; + + return $data; + }; + + add_filter( 'bp_activity_get_edit_data', $counter ); + $this->get_first_item( array( '_fields' => 'id,user_id' ) ); + remove_filter( 'bp_activity_get_edit_data', $counter ); + + $this->assertSame( 0, $builds ); + } + + /** + * Skip a test that needs the reactions feature. + */ + protected function require_reactions() { + if ( ! function_exists( 'bb_load_reaction' ) || ! bb_load_reaction() ) { + $this->markTestSkipped( 'The reactions feature is not available on this install.' ); + } + } + + /** + * The reaction lookups behind `reacted_*` and `favorite_count` are three + * of the most expensive things the feed does per row. An unselected + * reaction field must not reach the reaction tables at all. + */ + public function test_reaction_tables_are_untouched_when_no_reaction_field_is_selected() { + $this->require_reactions(); + + $this->assertSame( 0, $this->count_reaction_queries( array( '_fields' => 'id,user_id,date' ) ) ); + } + + /** + * ...and a selected one still does. + */ + public function test_reaction_tables_are_read_when_a_reaction_field_is_selected() { + $this->require_reactions(); + + $this->assertGreaterThan( 0, $this->count_reaction_queries( array( '_fields' => 'id,reacted_counts' ) ) ); + } + + /** + * A narrow selection must cost measurably fewer queries than no selection. + * + * The floor is deliberately conservative, because the exact saving depends + * on which components are active. Measured on the stock test install the + * saving is ~13 queries per row; before the controller honoured `_fields` + * the same comparison differed by ~1.5 per row, which is the churn of the + * caches those two requests happen to share. Three sits between the two + * with room on either side. Most of the saving is the reaction lookups, so + * the test stands down where that feature is absent. + */ + public function test_narrow_field_selection_runs_fewer_queries() { + $this->require_reactions(); + + $rows = 10; + $minimum_saved_row = 3; + + // Distinct authors, so the per-author lookups are not shared. + foreach ( self::factory()->user->create_many( $rows ) as $user_id ) { + self::factory()->activity->create( + array( + 'user_id' => $user_id, + 'component' => 'activity', + 'type' => 'activity_update', + ) + ); + } + + /* + * Warm up first. Some of what the feed touches is memoised outside the + * object cache, so an unwarmed first request would make whichever + * measurement ran first look more expensive than it is. + */ + $this->get_first_item( array( 'per_page' => $rows ) ); + + $full = $this->count_queries( array( 'per_page' => $rows ) ); + $narrow = $this->count_queries( + array( + 'per_page' => $rows, + '_fields' => 'id,user_id,date', + ) + ); + + $this->assertGreaterThanOrEqual( + $minimum_saved_row * $rows, + $full - $narrow, + sprintf( 'No selection cost %d queries, a narrow selection cost %d, over %d rows.', $full, $narrow, $rows ) + ); + } + + /** + * Fields added with `bp_rest_register_field()` are handed the prepared + * activity and read keys off it, so those keys have to survive a selection + * that does not name them. + */ + public function test_registered_rest_field_resolves_under_a_narrow_selection() { + $properties = $this->endpoint->get_item_schema(); + + if ( ! isset( $properties['properties']['can_report'] ) ) { + $this->markTestSkipped( 'Moderation is not active on this install.' ); + } + + $item = $this->get_first_item( array( '_fields' => 'id,can_report' ) ); + + $this->assertArrayHasKey( 'can_report', $item ); + } + + /** + * `DELETE` answers with an envelope, so its `_fields` names envelope keys. + * The activity nested under `previous` must still be built in full. + */ + public function test_delete_returns_a_whole_previous_activity() { + $request = new WP_REST_Request( 'DELETE', $this->endpoint_url . '/' . $this->activity_id ); + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'deleted,previous' ); + + $data = $this->dispatch( $request )->get_data(); + + $this->assertTrue( $data['deleted'] ); + $this->assertArrayHasKey( 'previous', $data ); + $this->assertArrayHasKey( 'content', $data['previous'] ); + $this->assertArrayHasKey( 'user_id', $data['previous'] ); + $this->assertArrayHasKey( 'activity_data', $data['previous'] ); + } + + /** + * The activity comment controller has a schema of its own, so its + * `_fields` must not narrow the activity items nested inside it. + */ + public function test_activity_comment_endpoint_returns_whole_comments() { + $this->create_comment(); + + $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id . '/comment' ); + $request->set_param( 'context', 'view' ); + $request->set_param( '_fields', 'comments' ); + + $data = $this->dispatch( $request )->get_data(); + + $this->assertArrayHasKey( 'comments', $data ); + $this->assertNotEmpty( $data['comments'] ); + + $comment = $data['comments'][0]; + + $this->assertArrayHasKey( 'content', $comment ); + $this->assertArrayHasKey( 'user_id', $comment ); + + $properties = $this->endpoint->get_item_schema(); + + if ( isset( $properties['properties']['can_report'] ) ) { + $this->assertArrayHasKey( 'can_report', $comment ); + } + } + + /** + * Add a comment to the fixture activity. + * + * @return int Activity comment ID. + */ + protected function create_comment() { + return bp_activity_new_comment( + array( + 'activity_id' => $this->activity_id, + 'parent_id' => $this->activity_id, + 'user_id' => $this->user_id, + 'content' => 'A comment on the fixture.', + 'skip_error_return' => true, + ) + ); + } +} From d3d4e6e01af0a305e628178c5f4947a734cb2ef0 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Mon, 17 Aug 2026 14:26:46 +0200 Subject: [PATCH 2/8] Improve comments --- ...lass-bp-rest-activity-comment-endpoint.php | 24 +- .../class-bp-rest-activity-endpoint.php | 164 ++++++++----- src/bp-core/bp-core-rest-api.php | 36 +++ .../testcases/activity/rest-fields.php | 227 +++++++++++++++++- 4 files changed, 374 insertions(+), 77 deletions(-) diff --git a/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php index b40e6f9ee3b..64c35988287 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-comment-endpoint.php @@ -126,6 +126,7 @@ public function register_routes() { * @apiPermission LoggedInUser if the site is in Private Network. * @apiParam {Number} id A unique numeric ID for the activity. * @apiParam {String=threaded,stream,false} [display_comments=threaded] Comments by default, stream for within stream display, threaded for below each activity item. + * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. */ public function get_items( $request ) { @@ -356,6 +357,7 @@ public function get_item_permissions_check( $request ) { * @apiParam {Number} [parent_id] ID of the parent activity/comment item. * @apiParam {String} content The content of the comment. * @apiParam {String=threaded,stream,false} [display_comments=threaded] Comments by default, stream for within stream display, threaded for below each activity item. + * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. */ public function create_item( $request ) { @@ -963,6 +965,14 @@ public function get_collection_params() { 'required' => true, ); + $params['comment_fields'] = array( + 'description' => __( 'Limit each returned comment to a comma separated list of fields. The request\'s own `_fields` cannot reach them, because comments are returned as a list.', 'buddyboss' ), + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + $params['display_comments'] = array( 'description' => __( 'Comments by default, stream for within stream display, threaded for below each activity item.', 'buddyboss' ), 'default' => 'threaded', @@ -1029,15 +1039,13 @@ protected function prepare_activity_comments( $comments, $request ) { /* * Activity comments are built by the activity controller, but this one - * answers with a payload of its own: `get_items()`, `create_item()` and - * `delete_item()` nest the comments inside an envelope, so the caller's - * `_fields` addresses that envelope and must not narrow the comments. - * Stripping it here covers every caller. `get_item()` and - * `update_item()` do return a bare comment and could honour a - * selection, but they prepare a single row, so they forgo that saving - * rather than leave a future caller free to forget the strip. + * answers with a payload of its own, so the caller's `_fields` + * addresses that payload and cannot reach the comments -- `get_items()`, + * `create_item()` and `delete_item()` return them inside an envelope, + * and WordPress hands a list back whole. `comment_fields` is the + * selection that does reach them; without it they are built in full. */ - $comment_request = $this->activity_endpoint->bb_rest_request_without_fields( $request ); + $comment_request = bb_rest_request_for_nested_item( $request, 'comment_fields' ); $comment_loaded_count = 0; foreach ( $comments as $comment ) { diff --git a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php index d2e968a6237..75187581a35 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php @@ -237,6 +237,7 @@ public function register_routes() { * @apiParam {String} [component] Limit result set to items with a specific active component. * @apiParam {String} [type] Limit result set to items with a specific activity type. * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. + * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. * @apiParam {Array=public,loggedin,onlyme,friends,media} [privacy] Privacy of the activity. * @apiParam {String=activity,group} [pin_type] Show pin activity of feed type. * @apiParam {Number} [topic_id] Limit result set to items with a specific topic ID. @@ -461,6 +462,7 @@ public function get_items_permissions_check( $request ) { * @apiPermission LoggedInUser * @apiParam {Number} id A unique numeric ID for the activity. * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. + * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. */ public function get_item( $request ) { $activity = $this->get_activity_object( $request ); @@ -1399,7 +1401,7 @@ public function delete_item( $request ) { // Get the activity before it's deleted. $activity = $this->get_activity_object( $request ); - $previous = $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ); + $previous = $this->prepare_item_for_response( $activity, bb_rest_request_for_nested_item( $request ) ); if ( 'activity_comment' === $activity->type ) { $retval = bp_activity_delete_comment( $activity->item_id, $activity->id ); @@ -1752,7 +1754,7 @@ public function update_pin( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) + $this->prepare_item_for_response( $activity, bb_rest_request_for_nested_item( $request ) ) ); $retval = array( @@ -1919,7 +1921,7 @@ public function update_close_comments( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) + $this->prepare_item_for_response( $activity, bb_rest_request_for_nested_item( $request ) ) ); $retval = array( @@ -2154,12 +2156,25 @@ function_exists( 'bp_is_activity_edit_enabled' ) */ $fields = $this->get_fields_for_response( $request ); + $include_content = rest_is_field_included( 'content', $fields ); + $include_preview_data = rest_is_field_included( 'preview_data', $fields ); + $include_link_embed_url = rest_is_field_included( 'link_embed_url', $fields ); + + // Embed URL stored against the activity, if any. Its metadata is already in hand. + $link_embed = $activity_metas['_link_embed'][0] ?? ''; + /* - * The embed resolution further down inspects the rendered content, so - * it has to be produced whenever either of them belongs in the response. + * `link_embed_url` comes straight from that metadata whenever the + * activity carries it. Only without it does the value fall out of the + * embed resolution, which is otherwise work done for `preview_data` + * alone. The resolution reads the rendered content, so that has to be + * produced for it as well as for `content` itself. */ - $include_content = rest_is_field_included( 'content', $fields ); - $include_embed_data = rest_is_field_included( 'preview_data', $fields ) || rest_is_field_included( 'link_embed_url', $fields ); + $include_embed_data = $include_preview_data || ( $include_link_embed_url && empty( $link_embed ) ); + + $include_is_comment_closed = rest_is_field_included( 'is_comment_closed', $fields ); + $include_can_close_comment = rest_is_field_included( 'can_close_comment', $fields ); + $include_comment_closed_notice = rest_is_field_included( 'comment_closed_notice', $fields ); $data = array(); @@ -2221,7 +2236,10 @@ function_exists( 'bp_is_activity_edit_enabled' ) } if ( $include_embed_data ) { - $data['preview_data'] = ''; + $data['preview_data'] = ''; + } + + if ( $include_link_embed_url ) { $data['link_embed_url'] = ''; } @@ -2251,15 +2269,31 @@ function_exists( 'bp_is_activity_edit_enabled' ) ) : 0; } - $data['is_comment_closed'] = function_exists( 'bb_is_close_activity_comments_enabled' ) && bb_is_close_activity_comments_enabled() ? bb_is_activity_comments_closed( $activity->id ) : false; - $data['activity_status'] = $activity->status; + /* + * Resolved once into a local, because the close-comment permission and + * the notice below are both derived from it: a guarded field must never + * depend on a value another guard produced. + */ + $is_comment_closed = ( + ( $include_is_comment_closed || $include_can_close_comment || $include_comment_closed_notice ) && + function_exists( 'bb_is_close_activity_comments_enabled' ) && + bb_is_close_activity_comments_enabled() + ) ? bb_is_activity_comments_closed( $activity->id ) : false; + + if ( $include_is_comment_closed ) { + $data['is_comment_closed'] = $is_comment_closed; + } - $data['bb_activity_post_feature_image'] = array(); - if ( ! empty( $activity->id ) ) { - if ( function_exists( 'bb_pro_activity_post_feature_image_instance' ) ) { - $feature_image_data = bb_pro_activity_post_feature_image_instance()->bb_get_feature_image_data( $activity->id ); - if ( ! empty( $feature_image_data ) ) { - $data['bb_activity_post_feature_image'] = $feature_image_data; + $data['activity_status'] = $activity->status; + + if ( rest_is_field_included( 'bb_activity_post_feature_image', $fields ) ) { + $data['bb_activity_post_feature_image'] = array(); + if ( ! empty( $activity->id ) ) { + if ( function_exists( 'bb_pro_activity_post_feature_image_instance' ) ) { + $feature_image_data = bb_pro_activity_post_feature_image_instance()->bb_get_feature_image_data( $activity->id ); + if ( ! empty( $feature_image_data ) ) { + $data['bb_activity_post_feature_image'] = $feature_image_data; + } } } } @@ -2269,14 +2303,12 @@ function_exists( 'bp_is_activity_edit_enabled' ) $data['feature_media'] = wp_get_attachment_image_url( get_post_thumbnail_id( $blog_id ), 'full' ); } - if ( $include_embed_data ) { - // Add iframe embedded data in separate object. - $link_embed = $activity_metas['_link_embed'][0] ?? ''; - - if ( ! empty( $link_embed ) ) { - $data['link_embed_url'] = $link_embed; - } + // Add iframe embedded data in separate object. + if ( $include_link_embed_url && ! empty( $link_embed ) ) { + $data['link_embed_url'] = $link_embed; + } + if ( $include_embed_data ) { if ( ! empty( $link_embed ) && method_exists( $bp->embed, 'autoembed' ) ) { $data['preview_data'] = $bp->embed->autoembed( '', $activity ); @@ -2312,12 +2344,14 @@ function_exists( 'bp_is_activity_edit_enabled' ) } } - // Add link preview data in separate object. - $link_preview = bp_activity_link_preview( '', $activity ); - if ( ! empty( $link_preview ) ) { - $data['preview_data'] = $link_preview; - } elseif ( empty( $link_preview ) && in_array( $activity->type, array( 'bbp_reply_create', 'bbp_topic_create' ), true ) ) { - $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, empty( $data['preview_data'] ) ); + // Add link preview data in separate object. It produces `preview_data` alone. + if ( $include_preview_data ) { + $link_preview = bp_activity_link_preview( '', $activity ); + if ( ! empty( $link_preview ) ) { + $data['preview_data'] = $link_preview; + } elseif ( empty( $link_preview ) && in_array( $activity->type, array( 'bbp_reply_create', 'bbp_topic_create' ), true ) ) { + $data['preview_data'] = $this->bp_rest_activity_remove_lazyload( $data['preview_data'], $activity, empty( $data['preview_data'] ) ); + } } } @@ -2402,15 +2436,13 @@ function_exists( 'bp_is_activity_edit_enabled' ) $data['can_pin'] = true; } - $include_can_close_comment = rest_is_field_included( 'can_close_comment', $fields ); - if ( $include_can_close_comment ) { $data['can_close_comment'] = false; } if ( function_exists( 'bb_is_close_activity_comments_enabled' ) && bb_is_close_activity_comments_enabled() ) { - if ( $data['is_comment_closed'] ) { + if ( $is_comment_closed && $include_comment_closed_notice ) { $data['comment_closed_notice'] = bb_get_close_activity_comments_notice( $activity->id ); } @@ -2418,7 +2450,7 @@ function_exists( 'bp_is_activity_edit_enabled' ) if ( $include_can_close_comment ) { $check_args = array( 'activity_id' => $activity->id, - 'action' => $data['is_comment_closed'] ? 'unclose_comments' : 'close_comments', + 'action' => $is_comment_closed ? 'unclose_comments' : 'close_comments', ); $retval = bb_activity_comments_close_action_allowed( $check_args ); @@ -2432,7 +2464,7 @@ function_exists( 'bp_is_activity_edit_enabled' ) $schema = $this->get_item_schema(); // Comment depth. - if ( 'activity_comment' === $activity->type && ! empty( $activity->depth ) ) { + if ( 'activity_comment' === $activity->type && ! empty( $activity->depth ) && rest_is_field_included( 'comment_depth', $fields ) ) { $data['comment_depth'] = $activity->depth; } @@ -2539,30 +2571,6 @@ function_exists( 'bp_is_activity_edit_enabled' ) return apply_filters( 'bp_rest_activity_prepare_value', $response, $request, $activity ); } - /** - * Clone a request with its `_fields` selection removed. - * - * A prepared activity is sometimes nested inside a response that is not an - * activity: the `previous` key of a delete, the `activity` key of a pin, - * close-comments or mute action, the `comments` list of its parent, or the - * payload of the activity comment controller. In each of those cases the - * caller's `_fields` names the keys of the outer response, so it must not - * narrow the nested activity — WordPress returns such nested items whole. - * - * @since BuddyBoss [BBVERSION] - * - * @param WP_REST_Request $request Full details about the request. - * - * @return WP_REST_Request Cloned request without the `_fields` parameter. - */ - public function bb_rest_request_without_fields( $request ) { - $request = clone $request; - - unset( $request['_fields'] ); - - return $request; - } - /** * Prepare activity comments. * @@ -2579,7 +2587,7 @@ protected function prepare_activity_comments( $comments, $request ) { return $data; } - $comment_request = $this->bb_rest_request_without_fields( $request ); + $comment_request = bb_rest_request_for_nested_item( $request, 'comment_fields' ); foreach ( $comments as $comment ) { $data[] = $this->prepare_response_for_collection( @@ -3005,7 +3013,7 @@ public function get_item_schema() { /** * Filters the activity schema. * - * @param string $schema The endpoint schema. + * @param array $schema The endpoint schema. */ return apply_filters( 'bp_rest_activity_schema', $this->add_additional_fields_schema( $this->schema ) ); } @@ -3287,6 +3295,32 @@ public function get_item_schema() { ), ); + /* + * Response fields the controller has always returned but never + * declared. They are appended rather than written into the array + * above so that the surrounding alignment is left alone. + */ + $schema['properties']['bb_activity_post_feature_image'] = array( + 'context' => array( 'embed', 'view', 'edit' ), + 'description' => __( 'Feature image data of the activity post.', 'buddyboss' ), + 'type' => 'object', + 'readonly' => true, + ); + + $schema['properties']['comment_closed_notice'] = array( + 'context' => array( 'embed', 'view', 'edit' ), + 'description' => __( 'Notice explaining who turned commenting off for the activity.', 'buddyboss' ), + 'type' => 'string', + 'readonly' => true, + ); + + $schema['properties']['comment_depth'] = array( + 'context' => array( 'embed', 'view', 'edit' ), + 'description' => __( 'Nesting level of an activity comment below its parent activity.', 'buddyboss' ), + 'type' => 'integer', + 'readonly' => true, + ); + // Avatars. if ( true === buddypress()->avatar->show_avatars ) { $avatar_properties = array(); @@ -3463,6 +3497,14 @@ public function get_collection_params() { 'validate_callback' => 'rest_validate_request_arg', ); + $params['comment_fields'] = array( + 'description' => __( 'Limit the comments of each activity to a comma separated list of fields. The activity\'s own `_fields` cannot reach them, because comments are returned as a list.', 'buddyboss' ), + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + $params['display_comments'] = array( 'description' => __( 'No comments by default, stream for within stream display, threaded for below each activity item.', 'buddyboss' ), 'default' => '', @@ -3872,7 +3914,7 @@ public function update_mute_unmute_notification( $request ) { // Prepare the response now the user favorites has been updated. $res_activity = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $activity, $this->bb_rest_request_without_fields( $request ) ) + $this->prepare_item_for_response( $activity, bb_rest_request_for_nested_item( $request ) ) ); $retval = array( diff --git a/src/bp-core/bp-core-rest-api.php b/src/bp-core/bp-core-rest-api.php index fca22a4003c..ea9ec46d869 100644 --- a/src/bp-core/bp-core-rest-api.php +++ b/src/bp-core/bp-core-rest-api.php @@ -455,6 +455,42 @@ function bb_rest_raw_content( $content ) { return apply_filters( 'bb_rest_raw_content', $content ); } +/** + * Get a copy of a request for an item nested inside another response. + * + * A controller sometimes prepares an item that is not the response itself: the + * `previous` key of a delete, the `activity` key of a pin action, or the + * comments listed under their parent. The caller's `_fields` addresses the + * outer response, and WordPress hands such nested items back whole, so the + * selection must never be allowed to narrow them. + * + * Where a controller offers a selection of its own for the nested items, pass + * that parameter's name as `$fields_param` and it takes the place of `_fields` + * while the nested item is built. With the parameter absent the item is built + * in full, exactly as it was before the controllers honoured `_fields`. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request $request Full details about the request. + * @param string $fields_param Optional. Name of the request parameter + * holding the field selection for the + * nested items. Default ''. + * + * @return WP_REST_Request Copy of the request, with `_fields` replaced or removed. + */ +function bb_rest_request_for_nested_item( $request, $fields_param = '' ) { + $nested_request = clone $request; + $nested_fields = ( '' !== $fields_param ) ? $request->get_param( $fields_param ) : ''; + + unset( $nested_request['_fields'] ); + + if ( ! empty( $nested_fields ) ) { + $nested_request->set_param( '_fields', $nested_fields ); + } + + return $nested_request; +} + /** * Set the global variable for the REST request. * diff --git a/tests/phpunit/testcases/activity/rest-fields.php b/tests/phpunit/testcases/activity/rest-fields.php index 8ca0f94299a..6fd0131d66c 100644 --- a/tests/phpunit/testcases/activity/rest-fields.php +++ b/tests/phpunit/testcases/activity/rest-fields.php @@ -132,6 +132,20 @@ public static function guarded_field_provider() { array( 'user_avatar' ), array( 'can_toggle_notification' ), array( 'is_receive_notification' ), + array( 'bb_activity_post_feature_image' ), + ); + } + + /** + * Response fields that shipped for years without a schema entry. + * + * @return array + */ + public static function undeclared_field_provider() { + return array( + array( 'bb_activity_post_feature_image' ), + array( 'comment_closed_notice' ), + array( 'comment_depth' ), ); } @@ -213,8 +227,12 @@ protected function count_reaction_queries( $params = array() ) { wp_cache_flush(); add_filter( 'query', $counter ); - $this->get_first_item( $params ); - remove_filter( 'query', $counter ); + + try { + $this->get_first_item( $params ); + } finally { + remove_filter( 'query', $counter ); + } return $count; } @@ -374,8 +392,12 @@ public function test_content_is_not_rendered_when_it_is_not_selected() { }; add_filter( 'bp_get_activity_content_body', $counter ); - $this->get_first_item( array( '_fields' => 'id,user_id' ) ); - remove_filter( 'bp_get_activity_content_body', $counter ); + + try { + $this->get_first_item( array( '_fields' => 'id,user_id' ) ); + } finally { + remove_filter( 'bp_get_activity_content_body', $counter ); + } $this->assertSame( 0, $renders ); } @@ -392,12 +414,71 @@ public function test_content_is_rendered_when_it_is_selected() { }; add_filter( 'bp_get_activity_content_body', $counter ); - $this->get_first_item( array( '_fields' => 'id,content' ) ); - remove_filter( 'bp_get_activity_content_body', $counter ); + + try { + $this->get_first_item( array( '_fields' => 'id,content' ) ); + } finally { + remove_filter( 'bp_get_activity_content_body', $counter ); + } $this->assertGreaterThan( 0, $renders ); } + /** + * `link_embed_url` is stored in the activity metadata whenever the activity + * has one, so selecting it alone must not drag in the embed resolution -- + * nor the content render that resolution reads. + */ + public function test_link_embed_url_alone_skips_the_embed_resolution() { + bp_activity_update_meta( $this->activity_id, '_link_embed', 'https://example.org/embedded' ); + wp_cache_delete( $this->activity_id, 'activity_meta' ); + + $renders = 0; + $counter = function ( $content ) use ( &$renders ) { + $renders++; + + return $content; + }; + + add_filter( 'bp_get_activity_content_body', $counter ); + + try { + $item = $this->get_first_item( + array( + 'include' => $this->activity_id, + '_fields' => 'id,link_embed_url', + ) + ); + } finally { + remove_filter( 'bp_get_activity_content_body', $counter ); + } + + $this->assertSame( 'https://example.org/embedded', $item['link_embed_url'] ); + $this->assertSame( 0, $renders ); + } + + /** + * `can_close_comment` is derived from whether comments are closed. That + * value has to be resolved independently of the `is_comment_closed` + * selection, or one guard would be reading what another produced. + */ + public function test_close_comment_permission_is_independent_of_is_comment_closed() { + $full = $this->get_first_item( array( 'include' => $this->activity_id ) ); + $narrow = $this->get_first_item( + array( + 'include' => $this->activity_id, + '_fields' => 'id,can_close_comment', + ) + ); + + if ( ! isset( $full['can_close_comment'] ) ) { + $this->markTestSkipped( 'Closing activity comments is not available on this install.' ); + } + + $this->assertArrayNotHasKey( 'is_comment_closed', $narrow ); + $this->assertSame( $full['can_close_comment'], $narrow['can_close_comment'] ); + } + /** * An unselected `activity_data` must not build the edit payload. */ @@ -410,8 +491,12 @@ public function test_activity_data_is_not_built_when_it_is_not_selected() { }; add_filter( 'bp_activity_get_edit_data', $counter ); - $this->get_first_item( array( '_fields' => 'id,user_id' ) ); - remove_filter( 'bp_activity_get_edit_data', $counter ); + + try { + $this->get_first_item( array( '_fields' => 'id,user_id' ) ); + } finally { + remove_filter( 'bp_activity_get_edit_data', $counter ); + } $this->assertSame( 0, $builds ); } @@ -495,6 +580,132 @@ public function test_narrow_field_selection_runs_fewer_queries() { ); } + /** + * A field the controller returns has to be in the schema, otherwise + * `get_fields_for_response()` cannot see it and it can never be guarded. + * + * @dataProvider undeclared_field_provider + * + * @param string $field Field name. + */ + public function test_previously_undeclared_field_is_in_the_schema( $field ) { + $schema = $this->endpoint->get_item_schema(); + + $this->assertArrayHasKey( $field, $schema['properties'] ); + $this->assertNotEmpty( $schema['properties'][ $field ]['readonly'] ); + } + + /** + * ...and being in the schema must not make it writable. + * + * @dataProvider undeclared_field_provider + * + * @param string $field Field name. + */ + public function test_previously_undeclared_field_is_not_a_request_argument( $field ) { + foreach ( array( WP_REST_Server::CREATABLE, WP_REST_Server::EDITABLE ) as $method ) { + $this->assertArrayNotHasKey( + $field, + $this->endpoint->get_endpoint_args_for_item_schema( $method ), + sprintf( 'Field "%s" became a writable argument.', $field ) + ); + } + } + + /** + * `comment_fields` is the selection that reaches nested comments, since + * the parent's `_fields` cannot: WordPress hands a list back whole. + */ + public function test_comment_fields_narrows_the_nested_comments() { + $this->create_comment(); + + $item = $this->get_first_item( + array( + 'display_comments' => 'threaded', + 'include' => $this->activity_id, + 'comment_fields' => 'id,content', + ) + ); + + $this->assertNotEmpty( $item['comments'] ); + + $comment = $item['comments'][0]; + + $this->assertArrayHasKey( 'id', $comment ); + $this->assertArrayHasKey( 'content', $comment ); + $this->assertArrayNotHasKey( 'activity_data', $comment ); + $this->assertArrayNotHasKey( 'reacted_counts', $comment ); + } + + /** + * ...and it applies to the comments only, never to their parent. + */ + public function test_comment_fields_leaves_the_parent_activity_whole() { + $this->create_comment(); + + $item = $this->get_first_item( + array( + 'display_comments' => 'threaded', + 'include' => $this->activity_id, + 'comment_fields' => 'id', + ) + ); + + $this->assertArrayHasKey( 'content', $item ); + $this->assertArrayHasKey( 'activity_data', $item ); + } + + /** + * The activity comment controller honours it too. + */ + public function test_activity_comment_endpoint_honours_comment_fields() { + $this->create_comment(); + + $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id . '/comment' ); + $request->set_param( 'context', 'view' ); + $request->set_param( 'comment_fields', 'id,content' ); + + $data = $this->dispatch( $request )->get_data(); + + $this->assertNotEmpty( $data['comments'] ); + + $comment = $data['comments'][0]; + + $this->assertArrayHasKey( 'content', $comment ); + $this->assertArrayNotHasKey( 'activity_data', $comment ); + } + + /** + * An unselected comment field must not be built for the comments either. + */ + public function test_comment_fields_skips_the_work_behind_unselected_comment_fields() { + $this->create_comment(); + + $builds = 0; + $counter = function ( $data ) use ( &$builds ) { + $builds++; + + return $data; + }; + + add_filter( 'bp_activity_get_edit_data', $counter ); + + try { + $this->get_first_item( + array( + 'display_comments' => 'threaded', + 'include' => $this->activity_id, + 'comment_fields' => 'id,content', + '_fields' => 'id,comments', + ) + ); + } finally { + remove_filter( 'bp_activity_get_edit_data', $counter ); + } + + $this->assertSame( 0, $builds ); + } + /** * Fields added with `bp_rest_register_field()` are handed the prepared * activity and read keys off it, so those keys have to survive a selection From 3674f68997616d51f058920bd64ee8fda37219e5 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Wed, 19 Aug 2026 17:45:59 +0200 Subject: [PATCH 3/8] Extend covered endpoints with fields support --- .../class-bp-rest-activity-endpoint.php | 10 + src/bp-core/bp-core-rest-api.php | 32 ++ .../class-bp-rest-document-endpoint.php | 398 +++++++++++---- .../classes/class-bp-rest-groups-endpoint.php | 360 +++++++++---- .../classes/class-bp-rest-media-endpoint.php | 180 +++++-- .../class-bp-rest-members-endpoint.php | 312 ++++++++---- .../classes/class-bp-rest-video-endpoint.php | 41 +- .../phpunit/testcases/groups/rest-fields.php | 297 +++++++++++ tests/phpunit/testcases/media/rest-fields.php | 482 ++++++++++++++++++ .../phpunit/testcases/members/rest-fields.php | 297 +++++++++++ 10 files changed, 2040 insertions(+), 369 deletions(-) create mode 100644 tests/phpunit/testcases/groups/rest-fields.php create mode 100644 tests/phpunit/testcases/media/rest-fields.php create mode 100644 tests/phpunit/testcases/members/rest-fields.php diff --git a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php index 75187581a35..50cefd56c6c 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php @@ -238,6 +238,7 @@ public function register_routes() { * @apiParam {String} [type] Limit result set to items with a specific activity type. * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. + * @apiParam {String} [attachment_fields] Comma separated list of fields to build for each returned media, video or document. * @apiParam {Array=public,loggedin,onlyme,friends,media} [privacy] Privacy of the activity. * @apiParam {String=activity,group} [pin_type] Show pin activity of feed type. * @apiParam {Number} [topic_id] Limit result set to items with a specific topic ID. @@ -463,6 +464,7 @@ public function get_items_permissions_check( $request ) { * @apiParam {Number} id A unique numeric ID for the activity. * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. + * @apiParam {String} [attachment_fields] Comma separated list of fields to build for each returned media, video or document. */ public function get_item( $request ) { $activity = $this->get_activity_object( $request ); @@ -3497,6 +3499,14 @@ public function get_collection_params() { 'validate_callback' => 'rest_validate_request_arg', ); + $params['attachment_fields'] = array( + 'description' => __( 'Limit the media, videos and documents of each activity to a comma separated list of fields. The activity\'s own `_fields` cannot reach them, because attachments are returned as a list.', 'buddyboss' ), + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + $params['comment_fields'] = array( 'description' => __( 'Limit the comments of each activity to a comma separated list of fields. The activity\'s own `_fields` cannot reach them, because comments are returned as a list.', 'buddyboss' ), 'default' => '', diff --git a/src/bp-core/bp-core-rest-api.php b/src/bp-core/bp-core-rest-api.php index ea9ec46d869..fc2333ac60b 100644 --- a/src/bp-core/bp-core-rest-api.php +++ b/src/bp-core/bp-core-rest-api.php @@ -491,6 +491,38 @@ function bb_rest_request_for_nested_item( $request, $fields_param = '' ) { return $nested_request; } +/** + * Carry a nested item's field selection onto the request that builds it. + * + * Some controllers prepare their nested items with a request they synthesise + * themselves -- the media, video and document attachments listed under an + * activity, for one. The caller's `_fields` addresses the outer item and + * WordPress hands a list back whole, so it can never reach them. + * + * A controller that offers a selection of its own for those items passes that + * parameter's name here, and it becomes the `_fields` of the request that + * builds them. With the parameter absent the request is left untouched and the + * items are built in full, exactly as they were before. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request $nested_request Request the nested items are built with. + * @param WP_REST_Request $request Full details about the caller's request. + * @param string $fields_param Name of the request parameter holding + * the selection for the nested items. + * + * @return WP_REST_Request The nested request, for chaining. + */ +function bb_rest_set_nested_item_fields( $nested_request, $request, $fields_param ) { + $nested_fields = ( $request instanceof WP_REST_Request ) ? $request->get_param( $fields_param ) : ''; + + if ( ! empty( $nested_fields ) ) { + $nested_request->set_param( '_fields', $nested_fields ); + } + + return $nested_request; +} + /** * Set the global variable for the REST request. * diff --git a/src/bp-document/classes/class-bp-rest-document-endpoint.php b/src/bp-document/classes/class-bp-rest-document-endpoint.php index e84c2b5cb64..8c03c04176f 100644 --- a/src/bp-document/classes/class-bp-rest-document-endpoint.php +++ b/src/bp-document/classes/class-bp-rest-document-endpoint.php @@ -1065,7 +1065,7 @@ public function delete_item( $request ) { $previous = ''; foreach ( $documents['documents'] as $document ) { $previous = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $document, $request ) + $this->prepare_item_for_response( $document, bb_rest_request_for_nested_item( $request ) ) ); } @@ -1349,44 +1349,162 @@ protected function prepare_links( $document ) { * @return array */ public function document_get_prepare_response( $document, $request ) { - $data = array( - 'id' => $document->id, - 'blog_id' => $document->blog_id, - 'attachment_id' => ( isset( $document->attachment_id ) ? $document->attachment_id : 0 ), - 'user_id' => $document->user_id, - 'title' => $document->title, - 'description' => ( ! empty( $document->description ) ? wp_specialchars_decode( $document->description, ENT_QUOTES ) : '' ), - 'type' => ( empty( $document->attachment_id ) ? 'folder' : 'document' ), - 'folder_id' => $document->parent, - 'group_id' => $document->group_id, - 'activity_id' => ( isset( $document->activity_id ) ? $document->activity_id : 0 ), - 'message_id' => ( isset( $document->message_id ) ? $document->message_id : 0 ), - 'hide_activity_actions' => false, - 'privacy' => $document->privacy, - 'menu_order' => ( isset( $document->menu_order ) ? $document->menu_order : 0 ), - 'date_created' => $document->date_created, - 'date_modified' => $document->date_modified, - 'group_name' => $document->group_name, - 'group_status' => ( bp_is_active( 'groups' ) && ! empty( $document->group_id ) ? bp_get_group_status( groups_get_group( $document->group_id ) ) : '' ), - 'visibility' => $document->visibility, - 'count' => 0, - 'download_url' => '', - 'extension' => '', - 'extension_description' => '', - 'svg_icon' => '', - 'filename' => '', - 'size' => '', - 'msg_preview' => '', - 'attachment_data' => ( isset( $document->attachment_data ) ? $document->attachment_data : array() ), - 'user_nicename' => get_the_author_meta( 'user_nicename', $document->user_id ), - 'user_login' => get_the_author_meta( 'user_login', $document->user_id ), - 'display_name' => bp_core_get_user_displayname( $document->user_id ), - 'user_permissions' => $this->get_document_current_user_permissions( $document, $request ), - ); + /* + * The fields the request asked for. When the request carries no + * `_fields`, this is every property of the item schema, so each of the + * branches below runs exactly as it did before the controller became + * field-aware. + */ + $fields = $this->get_fields_for_response( $request ); + + $include_hide_activity_actions = rest_is_field_included( 'hide_activity_actions', $fields ); + $include_count = rest_is_field_included( 'count', $fields ); + $include_download_url = rest_is_field_included( 'download_url', $fields ); + $include_extension = rest_is_field_included( 'extension', $fields ); + $include_extension_description = rest_is_field_included( 'extension_description', $fields ); + $include_svg_icon = rest_is_field_included( 'svg_icon', $fields ); + $include_msg_preview = rest_is_field_included( 'msg_preview', $fields ); + + /* + * The extension names the icon, its own description and the preview + * markup; the download URL is quoted inside that markup. Both are + * resolved into locals so that no guarded field depends on a value + * another guard produced. + */ + $needs_extension = $include_extension || $include_svg_icon || $include_extension_description || $include_msg_preview; + $needs_download_url = $include_download_url || $include_msg_preview; + + $data = array(); + + $data['id'] = $document->id; + + if ( rest_is_field_included( 'blog_id', $fields ) ) { + $data['blog_id'] = $document->blog_id; + } + + if ( rest_is_field_included( 'attachment_id', $fields ) ) { + $data['attachment_id'] = ( isset( $document->attachment_id ) ? $document->attachment_id : 0 ); + } + + if ( rest_is_field_included( 'user_id', $fields ) ) { + $data['user_id'] = $document->user_id; + } + + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = $document->title; + } + + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = ( ! empty( $document->description ) ? wp_specialchars_decode( $document->description, ENT_QUOTES ) : '' ); + } + + if ( rest_is_field_included( 'type', $fields ) ) { + $data['type'] = ( empty( $document->attachment_id ) ? 'folder' : 'document' ); + } + + if ( rest_is_field_included( 'folder_id', $fields ) ) { + $data['folder_id'] = $document->parent; + } + + if ( rest_is_field_included( 'group_id', $fields ) ) { + $data['group_id'] = $document->group_id; + } + + if ( rest_is_field_included( 'activity_id', $fields ) ) { + $data['activity_id'] = ( isset( $document->activity_id ) ? $document->activity_id : 0 ); + } + + if ( rest_is_field_included( 'message_id', $fields ) ) { + $data['message_id'] = ( isset( $document->message_id ) ? $document->message_id : 0 ); + } + + if ( $include_hide_activity_actions ) { + $data['hide_activity_actions'] = false; + } + + if ( rest_is_field_included( 'privacy', $fields ) ) { + $data['privacy'] = $document->privacy; + } + + if ( rest_is_field_included( 'menu_order', $fields ) ) { + $data['menu_order'] = ( isset( $document->menu_order ) ? $document->menu_order : 0 ); + } + + if ( rest_is_field_included( 'date_created', $fields ) ) { + $data['date_created'] = $document->date_created; + } + + if ( rest_is_field_included( 'date_modified', $fields ) ) { + $data['date_modified'] = $document->date_modified; + } + + if ( rest_is_field_included( 'group_name', $fields ) ) { + $data['group_name'] = $document->group_name; + } + + if ( rest_is_field_included( 'group_status', $fields ) ) { + $data['group_status'] = ( bp_is_active( 'groups' ) && ! empty( $document->group_id ) ? bp_get_group_status( groups_get_group( $document->group_id ) ) : '' ); + } + + if ( rest_is_field_included( 'visibility', $fields ) ) { + $data['visibility'] = $document->visibility; + } + + if ( $include_count ) { + $data['count'] = 0; + } + + if ( $include_download_url ) { + $data['download_url'] = ''; + } + + if ( $include_extension ) { + $data['extension'] = ''; + } + + if ( $include_extension_description ) { + $data['extension_description'] = ''; + } + + if ( $include_svg_icon ) { + $data['svg_icon'] = ''; + } + + if ( rest_is_field_included( 'filename', $fields ) ) { + $data['filename'] = ''; + } + + if ( rest_is_field_included( 'size', $fields ) ) { + $data['size'] = ''; + } + + if ( $include_msg_preview ) { + $data['msg_preview'] = ''; + } + + if ( rest_is_field_included( 'attachment_data', $fields ) ) { + $data['attachment_data'] = ( isset( $document->attachment_data ) ? $document->attachment_data : array() ); + } + + if ( rest_is_field_included( 'user_nicename', $fields ) ) { + $data['user_nicename'] = get_the_author_meta( 'user_nicename', $document->user_id ); + } + + if ( rest_is_field_included( 'user_login', $fields ) ) { + $data['user_login'] = get_the_author_meta( 'user_login', $document->user_id ); + } + + if ( rest_is_field_included( 'display_name', $fields ) ) { + $data['display_name'] = bp_core_get_user_displayname( $document->user_id ); + } + + if ( rest_is_field_included( 'user_permissions', $fields ) ) { + $data['user_permissions'] = $this->get_document_current_user_permissions( $document, $request ); + } // Below condition will check if document has comments then like/comment button will not visible for that particular media. - if ( ! empty( $data['activity_id'] ) && bp_is_active( 'activity' ) ) { - $activity = new BP_Activity_Activity( $data['activity_id'] ); + if ( $include_hide_activity_actions && ! empty( $document->activity_id ) && bp_is_active( 'activity' ) ) { + $activity = new BP_Activity_Activity( $document->activity_id ); if ( isset( $activity->secondary_item_id ) ) { $get_activity = new BP_Activity_Activity( $activity->secondary_item_id ); if ( @@ -1402,86 +1520,115 @@ public function document_get_prepare_response( $document, $request ) { } if ( ! empty( $document->attachment_id ) ) { - $data['download_url'] = bp_document_download_link( $document->attachment_id, $document->id ); - $data['extension'] = bp_document_extension( $document->attachment_id ); - $data['svg_icon'] = bp_document_svg_icon( $data['extension'], $document->attachment_id, 'svg' ); - $data['filename'] = basename( get_attached_file( $document->attachment_id ) ); - $data['size'] = bp_document_size_format( filesize( get_attached_file( $document->attachment_id ) ) ); - - $extension_lists = bp_document_extensions_list(); - if ( ! empty( $extension_lists ) && ! empty( $data['extension'] ) ) { - $extension_lists = array_column( $extension_lists, 'description', 'extension' ); - $extension_name = '.' . $data['extension']; - if ( ! empty( $extension_lists ) && ! empty( $data['extension'] ) && array_key_exists( $extension_name, $extension_lists ) ) { - $data['extension_description'] = esc_html( $extension_lists[ $extension_name ] ); - } - } + $download_url = $needs_download_url ? bp_document_download_link( $document->attachment_id, $document->id ) : ''; + $extension = $needs_extension ? bp_document_extension( $document->attachment_id ) : ''; - $output = ''; - ob_start(); + if ( $include_download_url ) { + $data['download_url'] = $download_url; + } - if ( in_array( $data['extension'], bp_get_document_preview_music_extensions(), true ) ) { - $audio_url = bp_document_get_preview_audio_url( $document->id, $document->attachment_id, $data['extension'] ); + if ( $include_extension ) { + $data['extension'] = $extension; + } - echo '
' . - '' . - '
'; + if ( $include_svg_icon ) { + $data['svg_icon'] = bp_document_svg_icon( $extension, $document->attachment_id, 'svg' ); + } + if ( rest_is_field_included( 'filename', $fields ) ) { + $data['filename'] = basename( get_attached_file( $document->attachment_id ) ); } - if ( function_exists( 'bp_document_get_preview_url' ) ) { - $attachment_url = bp_document_get_preview_url( $document->id, $document->attachment_id ); - } else { - $attachment_url = bp_document_get_preview_image_url( $document->id, $data['extension'], $document->attachment_id ); + if ( rest_is_field_included( 'size', $fields ) ) { + $data['size'] = bp_document_size_format( filesize( get_attached_file( $document->attachment_id ) ) ); } - if ( $attachment_url ) { - echo '
' . - '' . - '
'; + if ( $include_extension_description ) { + $extension_lists = bp_document_extensions_list(); + if ( ! empty( $extension_lists ) && ! empty( $extension ) ) { + $extension_lists = array_column( $extension_lists, 'description', 'extension' ); + $extension_name = '.' . $extension; + if ( ! empty( $extension_lists ) && ! empty( $extension ) && array_key_exists( $extension_name, $extension_lists ) ) { + $data['extension_description'] = esc_html( $extension_lists[ $extension_name ] ); + } + } } - $sizes = is_file( get_attached_file( $document->attachment_id ) ) ? get_attached_file( $document->attachment_id ) : 0; - if ( $sizes && filesize( $sizes ) / 1e+6 < 2 ) { - if ( in_array( $data['extension'], bp_get_document_preview_code_extensions(), true ) ) { - $data_temp = bp_document_get_preview_text_from_attachment( $document->attachment_id ); - $file_data = $data_temp['text']; - $more_text = $data_temp['more_text']; - - echo '
' . - '
' . - '' . - '
' . - '' . + + if ( $include_msg_preview ) { + $output = ''; + ob_start(); + + if ( in_array( $extension, bp_get_document_preview_music_extensions(), true ) ) { + $audio_url = bp_document_get_preview_audio_url( $document->id, $document->attachment_id, $extension ); + + echo '
' . + '' . '
'; - if ( true === $more_text ) { - printf( - /* translators: %s: download string */ - '
%s
', - sprintf( - /* translators: %s: download url */ - wp_kses_post( 'This file was truncated for preview. Please download to view the full file.', 'buddyboss' ), - esc_url( $data['download_url'] ) - ) - ); + } + + if ( function_exists( 'bp_document_get_preview_url' ) ) { + $attachment_url = bp_document_get_preview_url( $document->id, $document->attachment_id ); + } else { + $attachment_url = bp_document_get_preview_image_url( $document->id, $extension, $document->attachment_id ); + } + + if ( $attachment_url ) { + echo '
' . + '' . + '
'; + } + $sizes = is_file( get_attached_file( $document->attachment_id ) ) ? get_attached_file( $document->attachment_id ) : 0; + if ( $sizes && filesize( $sizes ) / 1e+6 < 2 ) { + if ( in_array( $extension, bp_get_document_preview_code_extensions(), true ) ) { + $data_temp = bp_document_get_preview_text_from_attachment( $document->attachment_id ); + $file_data = $data_temp['text']; + $more_text = $data_temp['more_text']; + + echo '
' . + '
' . + '' . + '
' . + '' . + '
'; + + if ( true === $more_text ) { + printf( + /* translators: %s: download string */ + '
%s
', + sprintf( + /* translators: %s: download url */ + wp_kses_post( 'This file was truncated for preview. Please download to view the full file.', 'buddyboss' ), + esc_url( $download_url ) + ) + ); + } } } - } - $output .= ob_get_clean(); + $output .= ob_get_clean(); - $data['msg_preview'] = $output; + $data['msg_preview'] = $output; + } } else { - $child_doc = count( bp_document_get_folder_document_ids( $document->id ) ); - $child_folder = count( $this->bp_document_get_folder_children_ids( $document->id ) ); - $data['count'] = (int) $child_doc + (int) $child_folder; - $data['svg_icon'] = bp_document_svg_icon( 'folder', '', 'svg' ); - $data['download_url'] = bp_document_folder_download_link( $document->id ); + if ( $include_count ) { + $child_doc = count( bp_document_get_folder_document_ids( $document->id ) ); + $child_folder = count( $this->bp_document_get_folder_children_ids( $document->id ) ); + $data['count'] = (int) $child_doc + (int) $child_folder; + } + + if ( $include_svg_icon ) { + $data['svg_icon'] = bp_document_svg_icon( 'folder', '', 'svg' ); + } + + if ( $include_download_url ) { + $data['download_url'] = bp_document_folder_download_link( $document->id ); + } } return $data; @@ -1494,6 +1641,15 @@ public function document_get_prepare_response( $document, $request ) { * @since 0.1.0 */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the document schema. + * + * @param array $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_document', @@ -1717,12 +1873,22 @@ public function get_item_schema() { ), ); - /** - * Filters the document schema. - * - * @param array $schema The endpoint schema. + /* + * A response field the controller has always returned but never + * declared. Appended rather than written into the array above so + * that the surrounding alignment is left alone. */ - return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $schema ) ); + $schema['properties']['user_permissions'] = array( + 'context' => array( 'embed', 'view', 'edit' ), + 'description' => __( 'Current user\'s permission with the document.', 'buddyboss' ), + 'readonly' => true, + 'type' => 'object', + ); + + $this->schema = $schema; + + /** This filter is documented in bp-document/classes/class-bp-rest-document-endpoint.php */ + return apply_filters( 'bp_rest_document_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** @@ -2350,10 +2516,11 @@ public function bp_rest_document_support() { * * @param BP_Activity_Activity $activity Activity Array. * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bp_documents_get_rest_field_callback( $activity, $attribute ) { + protected function bp_documents_get_rest_field_callback( $activity, $attribute, $request = null ) { $activity_id = $activity['id']; if ( empty( $activity_id ) ) { @@ -2423,6 +2590,7 @@ protected function bp_documents_get_rest_field_callback( $activity, $attribute ) $object = new WP_REST_Request(); $object->set_param( 'support', 'activity' ); $object->set_param( 'context', 'view' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $documents['documents'] as $document ) { $retval[] = $this->prepare_response_for_collection( @@ -2665,12 +2833,13 @@ protected function bp_rest_user_can_comment_upload_document( $activity, $attribu /** * The function to use to get documents of the messages REST Field. * - * @param array $data The message value for the REST response. - * @param string $attribute The REST Field key used into the REST response. + * @param array $data The message value for the REST response. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return array|void The value of the REST Field to include into the REST response. */ - protected function bp_documents_get_rest_field_callback_messages( $data, $attribute ) { + protected function bp_documents_get_rest_field_callback_messages( $data, $attribute, $request = null ) { $message_id = $data['id']; if ( empty( $message_id ) ) { @@ -2730,6 +2899,7 @@ protected function bp_documents_get_rest_field_callback_messages( $data, $attrib $retval = array(); $object = new WP_REST_Request(); $object->set_param( 'support', 'message' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $documents['documents'] as $document ) { $retval[] = $this->prepare_response_for_collection( @@ -2832,12 +3002,13 @@ protected function bp_documents_update_rest_field_callback_messages( $object, $v /** * The function to use to get documents of the topic/reply REST Field. * - * @param array $post WP_Post object as array. - * @param string $attribute The REST Field key used into the REST response. + * @param array $post WP_Post object as array. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bbp_document_get_rest_field_callback( $post, $attribute ) { + protected function bbp_document_get_rest_field_callback( $post, $attribute, $request = null ) { $p_id = $post['id']; @@ -2905,6 +3076,7 @@ protected function bbp_document_get_rest_field_callback( $post, $attribute ) { $retval = array(); $object = new WP_REST_Request(); $object->set_param( 'support', 'forums' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $documents['documents'] as $document ) { $retval[] = $this->prepare_response_for_collection( diff --git a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php index e9ffa9759d1..f4715bc82f9 100644 --- a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php +++ b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php @@ -634,7 +634,7 @@ public function delete_item( $request ) { // Get the group before it's deleted. $group = $this->get_group_object( $request ); - $previous = $this->prepare_item_for_response( $group, $request ); + $previous = $this->prepare_item_for_response( $group, bb_rest_request_for_nested_item( $request ) ); // Delete group forum. if ( isset( $request['delete_group_forum'] ) && true === $request['delete_group_forum'] ) { @@ -738,53 +738,177 @@ public function delete_item_permissions_check( $request ) { * @since 0.1.0 */ public function prepare_item_for_response( $item, $request ) { - $data = array( - 'id' => $item->id, - 'creator_id' => bp_get_group_creator_id( $item ), - 'parent_id' => $item->parent_id, - 'date_created' => bp_rest_prepare_date_response( $item->date_created ), - 'description' => array( + /* + * The fields the request asked for. When the request carries no + * `_fields`, this is every property of the item schema, so each of the + * branches below runs exactly as it did before the controller became + * field-aware. + */ + $fields = $this->get_fields_for_response( $request ); + + $include_types = rest_is_field_included( 'types', $fields ); + $include_group_type = rest_is_field_included( 'group_type', $fields ); + $include_group_type_label = rest_is_field_included( 'group_type_label', $fields ); + $include_role = rest_is_field_included( 'role', $fields ); + $include_plural_role = rest_is_field_included( 'plural_role', $fields ); + $include_admins = rest_is_field_included( 'admins', $fields ); + $include_mods = rest_is_field_included( 'mods', $fields ); + + /* + * Held in locals: the group type block refines them and the plural + * role falls back to the singular, so no guarded field may depend on + * a value another guard produced. + */ + $types = ( $include_types || $include_group_type ) ? bp_groups_get_group_type( $item->id, false ) : array(); + $group_type_label = ( $include_group_type_label || $include_group_type ) ? $this->get_group_type_label( $item ) : ''; + $role = ''; + + $data = array(); + + $data['id'] = $item->id; + + if ( rest_is_field_included( 'creator_id', $fields ) ) { + $data['creator_id'] = bp_get_group_creator_id( $item ); + } + + if ( rest_is_field_included( 'parent_id', $fields ) ) { + $data['parent_id'] = $item->parent_id; + } + + if ( rest_is_field_included( 'date_created', $fields ) ) { + $data['date_created'] = bp_rest_prepare_date_response( $item->date_created ); + } + + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = array( 'raw' => $item->description, 'rendered' => bp_get_group_description( $item ), - ), - 'enable_forum' => $this->bp_rest_group_is_forum_enabled( $item ), - 'link' => bp_get_group_permalink( $item ), - 'name' => bp_get_group_name( $item ), - 'name_raw' => $item->name, - 'slug' => bp_get_group_slug( $item ), - 'status' => bp_get_group_status( $item ), - 'types' => bp_groups_get_group_type( $item->id, false ), - 'group_type_label' => $this->get_group_type_label( $item ), - 'subgroups_id' => $this->bp_rest_get_sub_groups( $item->id ), - 'admins' => array(), - 'mods' => array(), - 'total_member_count' => null, - 'last_activity' => null, - 'is_member' => groups_is_user_member( get_current_user_id(), $item->id ) ? true : false, - 'invite_id' => groups_is_user_invited( get_current_user_id(), $item->id ), - 'request_id' => groups_is_user_pending( get_current_user_id(), $item->id ), - 'is_admin' => ( ! empty( groups_is_user_admin( get_current_user_id(), $item->id ) ) ? true : false ), - 'is_mod' => ( ! empty( groups_is_user_mod( get_current_user_id(), $item->id ) ) ? true : false ), - 'members_count' => groups_get_total_member_count( $item->id ), - 'role' => '', - 'plural_role' => '', - 'can_join' => $this->bp_rest_user_can_join( $item ), - 'can_post' => $this->bp_rest_user_can_post( $item ), - 'create_media' => ( bp_is_active( 'media' ) && groups_can_user_manage_media( bp_loggedin_user_id(), $item->id ) ), - 'create_album' => ( bp_is_active( 'media' ) && groups_can_user_manage_albums( bp_loggedin_user_id(), $item->id ) ), - 'create_video' => ( bp_is_active( 'video' ) && groups_can_user_manage_video( bp_loggedin_user_id(), $item->id ) ), - 'create_document' => ( bp_is_active( 'document' ) && groups_can_user_manage_document( bp_loggedin_user_id(), $item->id ) ), - 'can_schedule' => function_exists( 'bb_is_enabled_activity_schedule_posts' ) && - bb_is_enabled_activity_schedule_posts() && - function_exists( 'bb_can_user_schedule_activity' ) && - bb_can_user_schedule_activity( - array( - 'object' => 'group', - 'group_id' => $item->id, - 'user_id' => bp_loggedin_user_id(), - ) - ), - 'can_create_poll' => function_exists( 'bb_is_enabled_activity_post_polls' ) && + ); + } + + if ( rest_is_field_included( 'enable_forum', $fields ) ) { + $data['enable_forum'] = $this->bp_rest_group_is_forum_enabled( $item ); + } + + if ( rest_is_field_included( 'link', $fields ) ) { + $data['link'] = bp_get_group_permalink( $item ); + } + + if ( rest_is_field_included( 'name', $fields ) ) { + $data['name'] = bp_get_group_name( $item ); + } + + if ( rest_is_field_included( 'name_raw', $fields ) ) { + $data['name_raw'] = $item->name; + } + + if ( rest_is_field_included( 'slug', $fields ) ) { + $data['slug'] = bp_get_group_slug( $item ); + } + + if ( rest_is_field_included( 'status', $fields ) ) { + $data['status'] = bp_get_group_status( $item ); + } + + if ( $include_types ) { + $data['types'] = $types; + } + + if ( $include_group_type_label ) { + $data['group_type_label'] = $group_type_label; + } + + if ( rest_is_field_included( 'subgroups_id', $fields ) ) { + $data['subgroups_id'] = $this->bp_rest_get_sub_groups( $item->id ); + } + + if ( rest_is_field_included( 'admins', $fields ) ) { + $data['admins'] = array(); + } + + if ( rest_is_field_included( 'mods', $fields ) ) { + $data['mods'] = array(); + } + + if ( rest_is_field_included( 'total_member_count', $fields ) ) { + $data['total_member_count'] = null; + } + + if ( rest_is_field_included( 'last_activity', $fields ) ) { + $data['last_activity'] = null; + } + + if ( rest_is_field_included( 'is_member', $fields ) ) { + $data['is_member'] = groups_is_user_member( get_current_user_id(), $item->id ) ? true : false; + } + + if ( rest_is_field_included( 'invite_id', $fields ) ) { + $data['invite_id'] = groups_is_user_invited( get_current_user_id(), $item->id ); + } + + if ( rest_is_field_included( 'request_id', $fields ) ) { + $data['request_id'] = groups_is_user_pending( get_current_user_id(), $item->id ); + } + + if ( rest_is_field_included( 'is_admin', $fields ) ) { + $data['is_admin'] = ( ! empty( groups_is_user_admin( get_current_user_id(), $item->id ) ) ? true : false ); + } + + if ( rest_is_field_included( 'is_mod', $fields ) ) { + $data['is_mod'] = ( ! empty( groups_is_user_mod( get_current_user_id(), $item->id ) ) ? true : false ); + } + + if ( rest_is_field_included( 'members_count', $fields ) ) { + $data['members_count'] = groups_get_total_member_count( $item->id ); + } + + if ( $include_role ) { + $data['role'] = ''; + } + + if ( $include_plural_role ) { + $data['plural_role'] = ''; + } + + if ( rest_is_field_included( 'can_join', $fields ) ) { + $data['can_join'] = $this->bp_rest_user_can_join( $item ); + } + + if ( rest_is_field_included( 'can_post', $fields ) ) { + $data['can_post'] = $this->bp_rest_user_can_post( $item ); + } + + if ( rest_is_field_included( 'create_media', $fields ) ) { + $data['create_media'] = ( bp_is_active( 'media' ) && groups_can_user_manage_media( bp_loggedin_user_id(), $item->id ) ); + } + + if ( rest_is_field_included( 'create_album', $fields ) ) { + $data['create_album'] = ( bp_is_active( 'media' ) && groups_can_user_manage_albums( bp_loggedin_user_id(), $item->id ) ); + } + + if ( rest_is_field_included( 'create_video', $fields ) ) { + $data['create_video'] = ( bp_is_active( 'video' ) && groups_can_user_manage_video( bp_loggedin_user_id(), $item->id ) ); + } + + if ( rest_is_field_included( 'create_document', $fields ) ) { + $data['create_document'] = ( bp_is_active( 'document' ) && groups_can_user_manage_document( bp_loggedin_user_id(), $item->id ) ); + } + + if ( rest_is_field_included( 'can_schedule', $fields ) ) { + $data['can_schedule'] = function_exists( 'bb_is_enabled_activity_schedule_posts' ) && + bb_is_enabled_activity_schedule_posts() && + function_exists( 'bb_can_user_schedule_activity' ) && + bb_can_user_schedule_activity( + array( + 'object' => 'group', + 'group_id' => $item->id, + 'user_id' => bp_loggedin_user_id(), + ) + ); + } + + if ( rest_is_field_included( 'can_create_poll', $fields ) ) { + $data['can_create_poll'] = function_exists( 'bb_is_enabled_activity_post_polls' ) && bb_is_enabled_activity_post_polls( false ) && function_exists( 'bb_can_user_create_poll_activity' ) && bb_can_user_create_poll_activity( @@ -793,40 +917,48 @@ function_exists( 'bb_can_user_create_poll_activity' ) && 'group_id' => $item->id, 'user_id' => bp_loggedin_user_id(), ) - ), - ); + ); + } - // BuddyBoss Platform support. - if ( function_exists( 'bp_get_user_group_role_title' ) && bp_loggedin_user_id() ) { - $data['role'] = bp_get_user_group_role_title( bp_loggedin_user_id(), $item->id ); + if ( $include_role || $include_plural_role ) { + // BuddyBoss Platform support. + if ( function_exists( 'bp_get_user_group_role_title' ) && bp_loggedin_user_id() ) { + $role = bp_get_user_group_role_title( bp_loggedin_user_id(), $item->id ); - // BuddyPress support. - } elseif ( function_exists( 'bp_groups_get_group_roles' ) && bp_loggedin_user_id() ) { - $group_role = bp_groups_get_group_roles(); + // BuddyPress support. + } elseif ( function_exists( 'bp_groups_get_group_roles' ) && bp_loggedin_user_id() ) { + $group_role = bp_groups_get_group_roles(); - if ( groups_is_user_admin( bp_loggedin_user_id(), $item->id ) ) { - $data['role'] = $group_role['admin']->name; - } elseif ( groups_is_user_mod( bp_loggedin_user_id(), $item->id ) ) { - $data['role'] = $group_role['mod']->name; - } elseif ( groups_is_user_member( bp_loggedin_user_id(), $item->id ) ) { - $data['role'] = $group_role['member']->name; + if ( groups_is_user_admin( bp_loggedin_user_id(), $item->id ) ) { + $role = $group_role['admin']->name; + } elseif ( groups_is_user_mod( bp_loggedin_user_id(), $item->id ) ) { + $role = $group_role['mod']->name; + } elseif ( groups_is_user_member( bp_loggedin_user_id(), $item->id ) ) { + $role = $group_role['member']->name; + } + } + + if ( $include_role ) { + $data['role'] = $role; } } - if ( function_exists( 'bp_get_group_member_section_title' ) && bp_loggedin_user_id() ) { - $data['plural_role'] = $this->bp_get_group_member_section_title( $item->id, bp_loggedin_user_id() ); - if ( empty( $data['plural_role'] ) ) { - $data['plural_role'] = $data['role']; + if ( $include_plural_role ) { + if ( function_exists( 'bp_get_group_member_section_title' ) && bp_loggedin_user_id() ) { + $data['plural_role'] = $this->bp_get_group_member_section_title( $item->id, bp_loggedin_user_id() ); + if ( empty( $data['plural_role'] ) ) { + $data['plural_role'] = $role; + } + } else { + $data['plural_role'] = $role; } - } else { - $data['plural_role'] = $data['role']; } // Get item schema. $schema = $this->get_item_schema(); // Avatars. - if ( ! empty( $schema['properties']['avatar_urls'] ) ) { + if ( ! empty( $schema['properties']['avatar_urls'] ) && rest_is_field_included( 'avatar_urls', $fields ) ) { $data['avatar_urls'] = array( 'thumb' => bp_core_fetch_avatar( array( @@ -850,34 +982,43 @@ function_exists( 'bb_can_user_create_poll_activity' ) && // Cover Image. if ( ! empty( $schema['properties']['cover_url'] ) && function_exists( 'bp_get_group_cover_url' ) ) { - $data['cover_url'] = bp_get_group_cover_url( $item ); - $data['cover_is_default'] = ! bp_attachments_get_group_has_cover_image( $item->id ); + if ( rest_is_field_included( 'cover_url', $fields ) ) { + $data['cover_url'] = bp_get_group_cover_url( $item ); + } + + if ( rest_is_field_included( 'cover_is_default', $fields ) ) { + $data['cover_is_default'] = ! bp_attachments_get_group_has_cover_image( $item->id ); + } } - if ( $this->bp_rest_group_is_forum_enabled( $item ) && function_exists( 'bbpress' ) ) { + if ( rest_is_field_included( 'forum', $fields ) && $this->bp_rest_group_is_forum_enabled( $item ) && function_exists( 'bbpress' ) ) { $data['forum'] = groups_get_groupmeta( $item->id, 'forum_id' ); if ( is_array( $data['forum'] ) && ! empty( $data['forum'][0] ) ) { $data['forum'] = $data['forum'][0]; } else { $data['forum'] = 0; } - } else { + } elseif ( rest_is_field_included( 'forum', $fields ) ) { $data['forum'] = 0; } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; // Get group type(s). - if ( false === $data['types'] ) { - $data['types'] = array(); + if ( false === $types ) { + $types = array(); + } + + if ( $include_types ) { + $data['types'] = $types; } - if ( ! empty( $data['types'] ) ) { + if ( $include_group_type && ! empty( $types ) ) { $group_type_data = array(); - $group_type_data['group_type_label'] = isset( $data['group_type_label'] ) && ! empty( $data['group_type_label'] ) ? $data['group_type_label'] : ''; + $group_type_data['group_type_label'] = ! empty( $group_type_label ) ? $group_type_label : ''; $group_type_data['types'] = bp_groups_get_group_type( $item->id, false ); // Group type's label background and text color. - $group_type = isset( $data['types'][0] ) ? $data['types'][0] : ''; + $group_type = isset( $types[0] ) ? $types[0] : ''; $label_color_data = function_exists( 'bb_get_group_type_label_colors' ) ? bb_get_group_type_label_colors( $group_type ) : ''; if ( ! empty( $label_color_data ) ) { $group_type_data['label_colors'] = $label_color_data; @@ -915,20 +1056,28 @@ function_exists( 'bb_can_user_create_poll_activity' ) && // If this is the 'edit' context, fill in more details--similar to "populate_extras". if ( 'edit' === $context || 'view' === $context ) { - $data['last_activity'] = bp_rest_prepare_date_response( groups_get_groupmeta( $item->id, 'last_activity' ) ); + if ( rest_is_field_included( 'last_activity', $fields ) ) { + $data['last_activity'] = bp_rest_prepare_date_response( groups_get_groupmeta( $item->id, 'last_activity' ) ); + } // Add admins and moderators to their respective arrays. $args = array( 'admin' ); if ( 'edit' === $context ) { - $args[] = 'mod'; - $data['total_member_count'] = groups_get_total_member_count( $item->id ); + $args[] = 'mod'; + + if ( rest_is_field_included( 'total_member_count', $fields ) ) { + $data['total_member_count'] = groups_get_total_member_count( $item->id ); + } } - $admin_mods = groups_get_group_members( - array( - 'group_id' => $item->id, - 'group_role' => $args, + + $admin_mods = ( $include_admins || $include_mods ) + ? groups_get_group_members( + array( + 'group_id' => $item->id, + 'group_role' => $args, + ) ) - ); + : array( 'members' => array() ); foreach ( (array) $admin_mods['members'] as $user ) { $user->avatar = bp_core_fetch_avatar( @@ -952,22 +1101,30 @@ function_exists( 'bb_can_user_create_poll_activity' ) && unset( $user->{$private_key} ); } - if ( ! empty( $user->is_admin ) ) { + if ( $include_admins && ! empty( $user->is_admin ) ) { $data['admins'][] = $user; - } elseif ( ! empty( $user->is_mod ) ) { + } elseif ( $include_mods && ! empty( $user->is_mod ) ) { $data['mods'][] = $user; } } } // Member subscribed the group or not? - if ( function_exists( 'bb_is_enabled_subscription' ) && bb_is_enabled_subscription( 'group' ) ) { + $include_subscription = rest_is_field_included( 'is_subscribed', $fields ) || rest_is_field_included( 'subscribed_id', $fields ); + + if ( $include_subscription && function_exists( 'bb_is_enabled_subscription' ) && bb_is_enabled_subscription( 'group' ) ) { $subscribed = 0; if ( is_user_logged_in() && function_exists( 'bb_is_member_subscribed_group' ) ) { $subscribed = bb_is_member_subscribed_group( $item->id, bp_loggedin_user_id() ); } - $data['is_subscribed'] = ! empty( $subscribed ); - $data['subscribed_id'] = empty( $subscribed ) ? 0 : $subscribed; + + if ( rest_is_field_included( 'is_subscribed', $fields ) ) { + $data['is_subscribed'] = ! empty( $subscribed ); + } + + if ( rest_is_field_included( 'subscribed_id', $fields ) ) { + $data['subscribed_id'] = empty( $subscribed ) ? 0 : $subscribed; + } } $data = $this->add_additional_fields_to_object( $data, $request ); @@ -1319,6 +1476,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE * @since 0.1.0 */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the group schema. + * + * @param array $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_groups', @@ -1624,6 +1790,18 @@ public function get_item_schema() { ), ); + /* + * A response field the controller has always returned but never + * declared. Appended rather than written into the array above so + * that the surrounding alignment is left alone. + */ + $schema['properties']['can_create_poll'] = array( + 'context' => array( 'embed', 'view', 'edit' ), + 'description' => __( 'Whether the current user can create a poll in the group.', 'buddyboss' ), + 'readonly' => true, + 'type' => 'boolean', + ); + // Avatars. if ( ! bp_disable_group_avatar_uploads() ) { $avatar_properties = array(); @@ -1699,7 +1877,9 @@ public function get_item_schema() { * * @param array $schema The endpoint schema. */ - return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $schema ) ); + $this->schema = $schema; + + return apply_filters( 'bp_rest_group_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** diff --git a/src/bp-media/classes/class-bp-rest-media-endpoint.php b/src/bp-media/classes/class-bp-rest-media-endpoint.php index 899c804dd66..5615adee4e4 100644 --- a/src/bp-media/classes/class-bp-rest-media-endpoint.php +++ b/src/bp-media/classes/class-bp-rest-media-endpoint.php @@ -997,7 +997,7 @@ public function delete_items( $request ) { $previous = array(); foreach ( $medias['medias'] as $media ) { $previous[] = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $media, $request ) + $this->prepare_item_for_response( $media, bb_rest_request_for_nested_item( $request ) ) ); } @@ -1120,7 +1120,7 @@ public function delete_item( $request ) { $previous = ''; foreach ( $medias['medias'] as $media ) { $previous = $this->prepare_response_for_collection( - $this->prepare_item_for_response( $media, $request ) + $this->prepare_item_for_response( $media, bb_rest_request_for_nested_item( $request ) ) ); } @@ -1450,36 +1450,105 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE * @since 0.1.0 */ public function prepare_item_for_response( $media, $request ) { - $data = array( - 'id' => $media->id, - 'blog_id' => $media->blog_id, - 'attachment_id' => $media->attachment_id, - 'user_id' => $media->user_id, - 'title' => $media->title, - 'description' => wp_specialchars_decode( $media->description, ENT_QUOTES ), - 'album_id' => $media->album_id, - 'group_id' => $media->group_id, - 'activity_id' => $media->activity_id, - 'message_id' => $media->message_id, - 'hide_activity_actions' => false, - 'privacy' => $media->privacy, - 'menu_order' => $media->menu_order, - 'date_created' => $media->date_created, - 'attachment_data' => $media->attachment_data, - 'group_name' => ( isset( $media->group_name ) ? $media->group_name : '' ), - 'visibility' => ( isset( $media->visibility ) ? $media->visibility : '' ), - 'user_nicename' => get_the_author_meta( 'user_nicename', $media->user_id ), - 'user_login' => get_the_author_meta( 'user_login', $media->user_id ), - 'display_name' => bp_core_get_user_displayname( $media->user_id ), - 'url' => bp_media_get_preview_image_url( $media->id, $media->attachment_id, 'bb-media-photos-popup-image' ), - 'download_url' => bp_media_download_link( $media->attachment_id, $media->id ), - 'user_permissions' => $this->get_media_current_user_permissions( $media ), - 'type' => $media->type, - ); + /* + * The fields the request asked for. When the request carries no + * `_fields`, this is every property of the item schema, so each of the + * branches below runs exactly as it did before the controller became + * field-aware. + */ + $fields = $this->get_fields_for_response( $request ); + + // Both are resolved twice: once here, and again for a video below. + $include_url = rest_is_field_included( 'url', $fields ); + $include_download_url = rest_is_field_included( 'download_url', $fields ); + $include_hide_activity_actions = rest_is_field_included( 'hide_activity_actions', $fields ); + + $data = array(); + + $data['id'] = $media->id; + + if ( rest_is_field_included( 'blog_id', $fields ) ) { + $data['blog_id'] = $media->blog_id; + } + if ( rest_is_field_included( 'attachment_id', $fields ) ) { + $data['attachment_id'] = $media->attachment_id; + } + if ( rest_is_field_included( 'user_id', $fields ) ) { + $data['user_id'] = $media->user_id; + } + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = $media->title; + } + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = wp_specialchars_decode( $media->description, ENT_QUOTES ); + } + if ( rest_is_field_included( 'album_id', $fields ) ) { + $data['album_id'] = $media->album_id; + } + if ( rest_is_field_included( 'group_id', $fields ) ) { + $data['group_id'] = $media->group_id; + } + if ( rest_is_field_included( 'activity_id', $fields ) ) { + $data['activity_id'] = $media->activity_id; + } + if ( rest_is_field_included( 'message_id', $fields ) ) { + $data['message_id'] = $media->message_id; + } + + if ( $include_hide_activity_actions ) { + $data['hide_activity_actions'] = false; + } + + if ( rest_is_field_included( 'privacy', $fields ) ) { + $data['privacy'] = $media->privacy; + } + if ( rest_is_field_included( 'menu_order', $fields ) ) { + $data['menu_order'] = $media->menu_order; + } + if ( rest_is_field_included( 'date_created', $fields ) ) { + $data['date_created'] = $media->date_created; + } + if ( rest_is_field_included( 'attachment_data', $fields ) ) { + $data['attachment_data'] = $media->attachment_data; + } + if ( rest_is_field_included( 'group_name', $fields ) ) { + $data['group_name'] = ( isset( $media->group_name ) ? $media->group_name : '' ); + } + if ( rest_is_field_included( 'visibility', $fields ) ) { + $data['visibility'] = ( isset( $media->visibility ) ? $media->visibility : '' ); + } + + if ( rest_is_field_included( 'user_nicename', $fields ) ) { + $data['user_nicename'] = get_the_author_meta( 'user_nicename', $media->user_id ); + } + + if ( rest_is_field_included( 'user_login', $fields ) ) { + $data['user_login'] = get_the_author_meta( 'user_login', $media->user_id ); + } + + if ( rest_is_field_included( 'display_name', $fields ) ) { + $data['display_name'] = bp_core_get_user_displayname( $media->user_id ); + } + + if ( $include_url ) { + $data['url'] = bp_media_get_preview_image_url( $media->id, $media->attachment_id, 'bb-media-photos-popup-image' ); + } + + if ( $include_download_url ) { + $data['download_url'] = bp_media_download_link( $media->attachment_id, $media->id ); + } + + if ( rest_is_field_included( 'user_permissions', $fields ) ) { + $data['user_permissions'] = $this->get_media_current_user_permissions( $media ); + } + + if ( rest_is_field_included( 'type', $fields ) ) { + $data['type'] = $media->type; + } // Below condition will check if media has comments then like/comment button will not visible for that particular media. - if ( ! empty( $data['activity_id'] ) && bp_is_active( 'activity' ) ) { - $activity = new BP_Activity_Activity( $data['activity_id'] ); + if ( $include_hide_activity_actions && ! empty( $media->activity_id ) && bp_is_active( 'activity' ) ) { + $activity = new BP_Activity_Activity( $media->activity_id ); if ( isset( $activity->secondary_item_id ) ) { $get_activity = new BP_Activity_Activity( $activity->secondary_item_id ); if ( @@ -1495,12 +1564,16 @@ public function prepare_item_for_response( $media, $request ) { } if ( 'video' === $media->type ) { - add_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 ); - $data['url'] = bb_video_get_symlink( $media->id ); - remove_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 ); + if ( $include_url ) { + add_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 ); + $data['url'] = bb_video_get_symlink( $media->id ); + remove_filter( 'bb_check_ios_device', array( $this, 'bb_rest_disable_symlink' ), 1 ); + } // Update the download link for the video. - $data['download_url'] = bp_video_download_link( $media->attachment_id, $media->id ); + if ( $include_download_url ) { + $data['download_url'] = bp_video_download_link( $media->attachment_id, $media->id ); + } } $data = $this->add_additional_fields_to_object( $data, $request ); @@ -1585,6 +1658,15 @@ protected function prepare_links( $media ) { * @since 0.1.0 */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the media schema. + * + * @param array $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_media', @@ -1765,12 +1847,10 @@ public function get_item_schema() { ), ); - /** - * Filters the media schema. - * - * @param array $schema The endpoint schema. - */ - return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $schema ) ); + $this->schema = $schema; + + /** This filter is documented in bp-media/classes/class-bp-rest-media-endpoint.php */ + return apply_filters( 'bp_rest_media_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** @@ -2332,10 +2412,11 @@ public function bp_rest_media_support() { * * @param BP_Activity_Activity $activity Activity Array. * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bp_media_ids_get_rest_field_callback( $activity, $attribute ) { + protected function bp_media_ids_get_rest_field_callback( $activity, $attribute, $request = null ) { $activity_id = $activity['id']; if ( empty( $activity_id ) ) { @@ -2404,6 +2485,7 @@ protected function bp_media_ids_get_rest_field_callback( $activity, $attribute ) $retval = array(); $object = new WP_REST_Request(); $object->set_param( 'context', 'view' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $medias['medias'] as $media ) { $retval[] = $this->prepare_response_for_collection( @@ -2933,12 +3015,13 @@ public function bp_rest_message_query_arguments( $params ) { /** * The function to use to get medias of the topic REST Field. * - * @param array $post WP_Post object as array. - * @param string $attribute The REST Field key used into the REST response. + * @param array $post WP_Post object as array. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bbp_media_get_rest_field_callback( $post, $attribute ) { + protected function bbp_media_get_rest_field_callback( $post, $attribute, $request = null ) { $p_id = $post['id']; @@ -3005,6 +3088,7 @@ protected function bbp_media_get_rest_field_callback( $post, $attribute ) { $retval = array(); $object = new WP_REST_Request(); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $medias['medias'] as $media ) { $retval[] = $this->prepare_response_for_collection( @@ -3315,12 +3399,13 @@ protected function bp_rest_media_forums_embed_gif( $id ) { /** * The function to use to get medias of the messages REST Field. * - * @param array $data The message value for the REST response. - * @param string $attribute The REST Field key used into the REST response. + * @param array $data The message value for the REST response. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return array|void The value of the REST Field to include into the REST response. */ - protected function bp_media_ids_get_rest_field_callback_messages( $data, $attribute ) { + protected function bp_media_ids_get_rest_field_callback_messages( $data, $attribute, $request = null ) { $message_id = $data['id']; if ( empty( $message_id ) ) { @@ -3379,6 +3464,7 @@ protected function bp_media_ids_get_rest_field_callback_messages( $data, $attrib $retval = array(); $object = new WP_REST_Request(); $object->set_param( 'context', 'view' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $medias['medias'] as $media ) { $retval[] = $this->prepare_response_for_collection( diff --git a/src/bp-members/classes/class-bp-rest-members-endpoint.php b/src/bp-members/classes/class-bp-rest-members-endpoint.php index d6475b0b992..145013250f5 100644 --- a/src/bp-members/classes/class-bp-rest-members-endpoint.php +++ b/src/bp-members/classes/class-bp-rest-members-endpoint.php @@ -535,7 +535,7 @@ public function delete_item( $request ) { ); } - $previous = $this->prepare_item_for_response( $user, $request ); + $previous = $this->prepare_item_for_response( $user, bb_rest_request_for_nested_item( $request ) ); $status = false; if ( bp_core_delete_account( $user_id ) ) { $status = true; @@ -706,13 +706,33 @@ public function prepare_item_for_response( $user, $request ) { * @since 0.1.0 */ public function user_data( $user, $request ) { - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + + /* + * The fields the request asked for. When the request carries no + * `_fields`, this is every property of the item schema, so each of the + * branches below runs exactly as it did before the controller became + * field-aware. + */ + $fields = $this->get_fields_for_response( $request ); + + $include_member_types = rest_is_field_included( 'member_types', $fields ); + $include_is_wp_admin = rest_is_field_included( 'is_wp_admin', $fields ); + $include_xprofile = rest_is_field_included( 'xprofile', $fields ); + $user_data = get_userdata( $user->ID ); - $followers = $this->rest_bp_get_follower_ids( array( 'user_id' => $user->ID ) ); - $following = $this->rest_bp_get_following_ids( array( 'user_id' => $user->ID ) ); + /* + * Both of these materialise a whole list of IDs so that it can be + * counted, so neither is resolved unless its count was asked for. + */ + $followers = rest_is_field_included( 'followers', $fields ) ? $this->rest_bp_get_follower_ids( array( 'user_id' => $user->ID ) ) : array(); + $following = rest_is_field_included( 'following', $fields ) ? $this->rest_bp_get_following_ids( array( 'user_id' => $user->ID ) ) : array(); + + // Held in a local, because the fallbacks below refine it. $member_types = array(); if ( + $include_member_types && function_exists( 'bp_get_xprofile_member_type_field_id' ) && function_exists( 'bp_xprofile_get_hidden_fields_for_user' ) && ! in_array( bp_get_xprofile_member_type_field_id(), bp_xprofile_get_hidden_fields_for_user( $user->ID ), true ) @@ -720,78 +740,141 @@ function_exists( 'bp_xprofile_get_hidden_fields_for_user' ) && $member_types = bp_get_member_type( $user->ID, false ); } - $data = array( - 'id' => $user->ID, - 'name' => $user->display_name, - 'user_login' => $user->user_login, - 'link' => bp_core_get_user_domain( $user->ID, $user->user_nicename, $user->user_login ), - 'member_types' => $member_types, - 'roles' => array(), - 'capabilities' => array(), - 'extra_capabilities' => array(), - 'registered_date' => bp_rest_prepare_date_response( $user_data->user_registered ), - 'profile_name' => bp_core_get_user_displayname( $user->ID ), - 'last_activity' => $this->bp_rest_get_member_last_active( $user->ID, array( 'relative' => false ) ), - 'xprofile' => array(), - 'followers' => ! empty( $followers ) ? count( $followers ) : 0, - 'following' => ! empty( $following ) ? count( $following ) : 0, - 'is_wp_admin' => false, - ); + $data = array(); + + $data['id'] = $user->ID; + + if ( rest_is_field_included( 'name', $fields ) ) { + $data['name'] = $user->display_name; + } + + if ( rest_is_field_included( 'user_login', $fields ) ) { + $data['user_login'] = $user->user_login; + } + + if ( rest_is_field_included( 'link', $fields ) ) { + $data['link'] = bp_core_get_user_domain( $user->ID, $user->user_nicename, $user->user_login ); + } + + if ( $include_member_types ) { + $data['member_types'] = $member_types; + } + + if ( rest_is_field_included( 'roles', $fields ) ) { + $data['roles'] = array(); + } + + if ( rest_is_field_included( 'capabilities', $fields ) ) { + $data['capabilities'] = array(); + } + + if ( rest_is_field_included( 'extra_capabilities', $fields ) ) { + $data['extra_capabilities'] = array(); + } - // Fetch user roles. - $user_roles = ! empty( $user->ID ) ? $user_data->roles : ''; - if ( ! empty( $user_roles ) ) { - // If user is admin then set true, otherwise it should be false. - $data['is_wp_admin'] = in_array( 'administrator', $user_roles, true ) ? true : false; + if ( rest_is_field_included( 'registered_date', $fields ) ) { + $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered ); + } + + if ( rest_is_field_included( 'profile_name', $fields ) ) { + $data['profile_name'] = bp_core_get_user_displayname( $user->ID ); + } + + if ( rest_is_field_included( 'last_activity', $fields ) ) { + $data['last_activity'] = $this->bp_rest_get_member_last_active( $user->ID, array( 'relative' => false ) ); + } + + if ( $include_xprofile ) { + $data['xprofile'] = array(); + } + + if ( rest_is_field_included( 'followers', $fields ) ) { + $data['followers'] = ! empty( $followers ) ? count( $followers ) : 0; + } + + if ( rest_is_field_included( 'following', $fields ) ) { + $data['following'] = ! empty( $following ) ? count( $following ) : 0; + } + + if ( $include_is_wp_admin ) { + $data['is_wp_admin'] = false; + + // Fetch user roles. + $user_roles = ! empty( $user->ID ) ? $user_data->roles : ''; + if ( ! empty( $user_roles ) ) { + // If user is admin then set true, otherwise it should be false. + $data['is_wp_admin'] = in_array( 'administrator', $user_roles, true ) ? true : false; + } } // Load xprofile data when required. - if ( 'embed' !== $context ) { + if ( $include_xprofile && 'embed' !== $context ) { $data['xprofile'] = $this->xprofile_data( $user->ID ); } - $data['friendship_status'] = ( - ( - bp_is_active( 'friends' ) - && function_exists( 'friends_check_friendship_status' ) - ) - ? friends_check_friendship_status( get_current_user_id(), $user->ID ) - : '' - ); + if ( rest_is_field_included( 'friendship_status', $fields ) ) { + $data['friendship_status'] = ( + ( + bp_is_active( 'friends' ) + && function_exists( 'friends_check_friendship_status' ) + ) + ? friends_check_friendship_status( get_current_user_id(), $user->ID ) + : '' + ); + } - $data['friendship_id'] = ( - ( - bp_is_active( 'friends' ) - && function_exists( 'friends_get_friendship_id' ) - ) - ? friends_get_friendship_id( get_current_user_id(), $user->ID ) - : '' - ); + if ( rest_is_field_included( 'friendship_id', $fields ) ) { + $data['friendship_id'] = ( + ( + bp_is_active( 'friends' ) + && function_exists( 'friends_get_friendship_id' ) + ) + ? friends_get_friendship_id( get_current_user_id(), $user->ID ) + : '' + ); + } - $data['create_friendship'] = ( bp_is_active( 'friends' ) && is_user_logged_in() && apply_filters( 'bp_rest_user_can_create_friendship', true, $user->ID ) ); + if ( rest_is_field_included( 'create_friendship', $fields ) ) { + $data['create_friendship'] = ( bp_is_active( 'friends' ) && is_user_logged_in() && apply_filters( 'bp_rest_user_can_create_friendship', true, $user->ID ) ); + } - $data['is_following'] = (bool) ( - function_exists( 'bp_is_following' ) - ? bp_is_following( - array( - 'leader_id' => $user->ID, - 'follower_id' => get_current_user_id(), + if ( rest_is_field_included( 'is_following', $fields ) ) { + $data['is_following'] = (bool) ( + function_exists( 'bp_is_following' ) + ? bp_is_following( + array( + 'leader_id' => $user->ID, + 'follower_id' => get_current_user_id(), + ) ) - ) - : '0' - ); + : '0' + ); + } - $data['can_follow'] = bp_is_active( 'activity' ) && function_exists( 'bp_is_activity_follow_active' ) && bp_is_activity_follow_active();; + if ( rest_is_field_included( 'can_follow', $fields ) ) { + $data['can_follow'] = bp_is_active( 'activity' ) && function_exists( 'bp_is_activity_follow_active' ) && bp_is_activity_follow_active(); + } if ( 'edit' === $context ) { - $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered ); - $data['roles'] = (array) array_values( $user_data->roles ); - $data['capabilities'] = (array) array_keys( $user_data->allcaps ); - $data['extra_capabilities'] = (array) array_keys( $user_data->caps ); + if ( rest_is_field_included( 'registered_date', $fields ) ) { + $data['registered_date'] = bp_rest_prepare_date_response( $user_data->user_registered ); + } + + if ( rest_is_field_included( 'roles', $fields ) ) { + $data['roles'] = (array) array_values( $user_data->roles ); + } + + if ( rest_is_field_included( 'capabilities', $fields ) ) { + $data['capabilities'] = (array) array_keys( $user_data->allcaps ); + } + + if ( rest_is_field_included( 'extra_capabilities', $fields ) ) { + $data['extra_capabilities'] = (array) array_keys( $user_data->caps ); + } } // The name used for that user in @-mentions. - if ( bp_is_active( 'activity' ) ) { + if ( rest_is_field_included( 'mention_name', $fields ) && bp_is_active( 'activity' ) ) { $data['mention_name'] = bp_activity_get_user_mentionname( $user->ID ); } @@ -799,7 +882,7 @@ function_exists( 'bp_is_following' ) $schema = $this->get_item_schema(); // Avatars. - if ( ! empty( $schema['properties']['avatar_urls'] ) ) { + if ( ! empty( $schema['properties']['avatar_urls'] ) && rest_is_field_included( 'avatar_urls', $fields ) ) { $blocked_by_show_avatar = false; $group_ids = $request->get_param( 'group_id' ); if ( ! empty( $group_ids ) ) { @@ -841,65 +924,77 @@ function_exists( 'bp_is_following' ) } // Cover Image. - $data['cover_url'] = ( - empty( bp_disable_cover_image_uploads() ) - ? bp_attachments_get_attachment( - 'url', - array( - 'object_dir' => 'members', - 'item_id' => $user->ID, + if ( rest_is_field_included( 'cover_url', $fields ) ) { + $data['cover_url'] = ( + empty( bp_disable_cover_image_uploads() ) + ? bp_attachments_get_attachment( + 'url', + array( + 'object_dir' => 'members', + 'item_id' => $user->ID, + ) ) - ) - : false - ); - $data['cover_is_default'] = ! bp_attachments_get_user_has_cover_image( $user->ID ); + : false + ); + } + + if ( rest_is_field_included( 'cover_is_default', $fields ) ) { + $data['cover_is_default'] = ! bp_attachments_get_user_has_cover_image( $user->ID ); + } // Fallback. - if ( false === $data['member_types'] ) { - $data['member_types'] = array(); + if ( false === $member_types ) { + $member_types = array(); } if ( function_exists( 'bp_member_type_enable_disable' ) && bp_member_type_enable_disable() === false ) { - $data['member_types'] = array(); + $member_types = array(); } - if ( ! empty( $data['member_types'] ) ) { - $member_types = array(); - foreach ( $data['member_types'] as $name ) { - $member_types[ $name ] = bp_get_member_type_object( $name ); + if ( ! empty( $member_types ) ) { + $member_type_objects = array(); + foreach ( $member_types as $name ) { + $member_type_objects[ $name ] = bp_get_member_type_object( $name ); // Member type's label background and text color. $label_color_data = function_exists( 'bb_get_member_type_label_colors' ) ? bb_get_member_type_label_colors( $name ) : ''; if ( ! empty( $label_color_data ) ) { - $member_types[ $name ]->label_colors = $label_color_data; + $member_type_objects[ $name ]->label_colors = $label_color_data; } } + $member_types = $member_type_objects; + } + + // Re-assigning keeps the key where it already sits in the response. + if ( $include_member_types ) { $data['member_types'] = $member_types; } - // It will check non-admin members can send message or not before they can connected to each other. - $allowed_message = false; + if ( rest_is_field_included( 'can_send_message', $fields ) ) { + // It will check non-admin members can send message or not before they can connected to each other. + $allowed_message = false; - if ( - bp_is_active( 'messages' ) && - bb_messages_user_can_send_message( - array( - 'sender_id' => bp_loggedin_user_id(), - 'recipients_id' => $user->ID, + if ( + bp_is_active( 'messages' ) && + bb_messages_user_can_send_message( + array( + 'sender_id' => bp_loggedin_user_id(), + 'recipients_id' => $user->ID, + ) ) - ) - ) { - $allowed_message = true; - } + ) { + $allowed_message = true; + } - // It will check non-admin members can send message or not before they can connected to each other. - // Also check access controls settings. - $data['can_send_message'] = ( - bp_is_active( 'messages' ) && - bp_loggedin_user_id() && - apply_filters( 'bp_rest_user_can_show_send_message_button', true, $user->ID ) && - $allowed_message - ); + // It will check non-admin members can send message or not before they can connected to each other. + // Also check access controls settings. + $data['can_send_message'] = ( + bp_is_active( 'messages' ) && + bp_loggedin_user_id() && + apply_filters( 'bp_rest_user_can_show_send_message_button', true, $user->ID ) && + $allowed_message + ); + } return $data; } @@ -1126,6 +1221,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE * @since 0.1.0 */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the members schema. + * + * @param array $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_members', @@ -1336,12 +1440,10 @@ public function get_item_schema() { 'readonly' => true, ); - /** - * Filters the members schema. - * - * @param array $schema The endpoint schema. - */ - return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $schema ) ); + $this->schema = $schema; + + /** This filter is documented in bp-members/classes/class-bp-rest-members-endpoint.php */ + return apply_filters( 'bp_rest_members_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** diff --git a/src/bp-video/classes/class-bp-rest-video-endpoint.php b/src/bp-video/classes/class-bp-rest-video-endpoint.php index 8cfc3158f73..e97a8f2239e 100644 --- a/src/bp-video/classes/class-bp-rest-video-endpoint.php +++ b/src/bp-video/classes/class-bp-rest-video-endpoint.php @@ -1091,7 +1091,7 @@ public function delete_item( $request ) { $previous = ''; foreach ( $videos['videos'] as $video ) { $previous = $this->prepare_response_for_collection( - $this->media_endpoint->prepare_item_for_response( $video, $request ) + $this->media_endpoint->prepare_item_for_response( $video, bb_rest_request_for_nested_item( $request ) ) ); } @@ -1286,6 +1286,15 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE * @since 0.1.0 */ public function get_item_schema() { + if ( ! empty( $this->schema ) ) { + /** + * Filters the video schema. + * + * @param array $schema The endpoint schema. + */ + return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $this->schema ) ); + } + $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'bp_video', @@ -1466,12 +1475,10 @@ public function get_item_schema() { ), ); - /** - * Filters the video schema. - * - * @param array $schema The endpoint schema. - */ - return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $schema ) ); + $this->schema = $schema; + + /** This filter is documented in bp-video/classes/class-bp-rest-video-endpoint.php */ + return apply_filters( 'bp_rest_video_schema', $this->add_additional_fields_schema( $this->schema ) ); } /** @@ -1898,10 +1905,11 @@ public function bp_rest_video_support() { * * @param BP_Activity_Activity $activity Activity Array. * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bp_video_ids_get_rest_field_callback( $activity, $attribute ) { + protected function bp_video_ids_get_rest_field_callback( $activity, $attribute, $request = null ) { $activity_id = $activity['id']; if ( empty( $activity_id ) ) { @@ -1942,6 +1950,7 @@ protected function bp_video_ids_get_rest_field_callback( $activity, $attribute ) $retval = array(); $object = new WP_REST_Request(); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $videos['videos'] as $video ) { $retval[] = $this->prepare_response_for_collection( $this->media_endpoint->prepare_item_for_response( $video, $object ) @@ -2185,12 +2194,13 @@ public function bp_rest_message_query_arguments( $params ) { /** * The function to use to get videos of the topic REST Field. * - * @param array $post WP_Post object as array. - * @param string $attribute The REST Field key used into the REST response. + * @param array $post WP_Post object as array. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return string The value of the REST Field to include into the REST response. */ - protected function bbp_video_get_rest_field_callback( $post, $attribute ) { + protected function bbp_video_get_rest_field_callback( $post, $attribute, $request = null ) { $p_id = $post['id']; @@ -2227,6 +2237,7 @@ protected function bbp_video_get_rest_field_callback( $post, $attribute ) { $retval = array(); $object = new WP_REST_Request(); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $videos['videos'] as $video ) { $retval[] = $this->prepare_response_for_collection( @@ -2383,12 +2394,13 @@ protected function bbp_video_update_rest_field_callback( $object, $value ) { /** * The function to use to get videos of the messages REST Field. * - * @param array $data The message value for the REST response. - * @param string $attribute The REST Field key used into the REST response. + * @param array $data The message value for the REST response. + * @param string $attribute The REST Field key used into the REST response. + * @param WP_REST_Request $request Full details about the request. * * @return array|void The value of the REST Field to include into the REST response. */ - protected function bp_video_ids_get_rest_field_callback_messages( $data, $attribute ) { + protected function bp_video_ids_get_rest_field_callback_messages( $data, $attribute, $request = null ) { $message_id = $data['id']; if ( empty( $message_id ) ) { @@ -2448,6 +2460,7 @@ protected function bp_video_ids_get_rest_field_callback_messages( $data, $attrib $retval = array(); $object = new WP_REST_Request(); $object->set_param( 'context', 'view' ); + bb_rest_set_nested_item_fields( $object, $request, 'attachment_fields' ); foreach ( $videos['videos'] as $video ) { $retval[] = $this->prepare_response_for_collection( diff --git a/tests/phpunit/testcases/groups/rest-fields.php b/tests/phpunit/testcases/groups/rest-fields.php new file mode 100644 index 00000000000..602d8fcb4c1 --- /dev/null +++ b/tests/phpunit/testcases/groups/rest-fields.php @@ -0,0 +1,297 @@ +markTestSkipped( 'Groups are not active on this install.' ); + } + + if ( ! get_term_by( 'name', 'invites-member-invite', bp_get_email_tax_type() ) ) { + require_once buddypress()->plugin_dir . 'bp-core/admin/bp-core-admin-schema.php'; + bp_core_install_emails(); + } + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + $this->server = $wp_rest_server; + + $this->endpoint = new BP_REST_Groups_Endpoint(); + $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/groups'; + + $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $this->user_id ); + + $this->group_id = self::factory()->group->create( + array( + 'creator_id' => $this->user_id, + 'name' => 'Field selection fixture', + 'status' => 'public', + ) + ); + } + + /** + * Drop the REST server so the next test builds a fresh one. + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tearDown(); + } + + /** + * Dispatch the way `WP_REST_Server::serve_request()` does. + * + * @param array $params Request parameters. + * + * @return array First group in the collection. + */ + protected function get_first_group( $params = array() ) { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + $response = $this->server->dispatch( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + $data = $response->get_data(); + + $this->assertNotEmpty( $data, 'The groups collection came back empty.' ); + + return $data[0]; + } + + /** + * Count how many times a hook fires while a request is dispatched. + * + * @param string $hook Hook name. + * @param array $params Request parameters. + * + * @return int + */ + protected function count_hook( $hook, $params ) { + $calls = 0; + + $counter = function ( $value ) use ( &$calls ) { + $calls++; + + return $value; + }; + + add_filter( $hook, $counter ); + + try { + $this->get_first_group( $params ); + } finally { + remove_filter( $hook, $counter ); + } + + return $calls; + } + + /** + * Count the queries one groups request costs, from a cold cache. + * + * @param array $params Request parameters. + * + * @return int + */ + protected function count_queries( $params ) { + global $wpdb; + + wp_cache_flush(); + + $before = $wpdb->num_queries; + $this->get_first_group( $params ); + + return $wpdb->num_queries - $before; + } + + /** + * A request that sends no `_fields` must still return every field the + * `view` context exposes. + */ + public function test_no_field_selection_returns_every_view_field() { + $group = $this->get_first_group(); + $schema = $this->endpoint->get_item_schema(); + + foreach ( $schema['properties'] as $field => $property ) { + if ( empty( $property['context'] ) || ! in_array( 'view', (array) $property['context'], true ) ) { + continue; + } + + // Only set when the group actually carries one. + if ( in_array( $field, array( 'group_type', 'cover_url', 'cover_is_default', 'is_subscribed', 'subscribed_id' ), true ) ) { + continue; + } + + $this->assertArrayHasKey( $field, $group, sprintf( 'Group field "%s" went missing.', $field ) ); + } + } + + /** + * Every key the unfiltered response returns has to be declared in the + * schema. An undeclared one cannot be guarded -- it would disappear from + * responses that send no `_fields` at all. + */ + public function test_every_returned_field_is_declared_in_the_schema() { + $schema = $this->endpoint->get_item_schema(); + $item = $this->get_first_group(); + + foreach ( array_keys( $item ) as $field ) { + if ( '_links' === $field || '_embedded' === $field ) { + continue; + } + + $this->assertArrayHasKey( + $field, + $schema['properties'], + sprintf( 'The groups controller returns "%s" but does not declare it.', $field ) + ); + } + } + + /** + * A narrow selection returns exactly the requested keys. + */ + public function test_narrow_field_selection_returns_only_the_requested_keys() { + $group = $this->get_first_group( array( '_fields' => 'id,name,slug' ) ); + + $actual = array_keys( $group ); + sort( $actual ); + + $this->assertSame( array( 'id', 'name', 'slug' ), $actual ); + } + + /** + * Avatars cost two resolutions per row. + */ + public function test_avatars_are_not_fetched_when_not_selected() { + $this->assertSame( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,name' ) ) ); + } + + /** + * ...but a selected one still is. + */ + public function test_avatars_are_fetched_when_selected() { + $schema = $this->endpoint->get_item_schema(); + + if ( empty( $schema['properties']['avatar_urls'] ) ) { + $this->markTestSkipped( 'Avatars are disabled on this install.' ); + } + + $this->assertGreaterThan( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,avatar_urls' ) ) ); + } + + /** + * The admin and moderator lists cost a member query plus an avatar each; + * they must not be assembled unless asked for. + */ + public function test_admin_list_is_not_assembled_when_not_selected() { + $group = $this->get_first_group( array( '_fields' => 'id,name' ) ); + + $this->assertArrayNotHasKey( 'admins', $group ); + } + + /** + * ...and is still assembled when it is. + */ + public function test_admin_list_is_assembled_when_selected() { + $group = $this->get_first_group( array( '_fields' => 'id,admins' ) ); + + $this->assertArrayHasKey( 'admins', $group ); + $this->assertNotEmpty( $group['admins'] ); + } + + /** + * `plural_role` falls back to `role`; that fallback must not depend on + * `role` having been selected. + */ + public function test_plural_role_resolves_without_role_being_selected() { + $full = $this->get_first_group(); + $narrow = $this->get_first_group( array( '_fields' => 'id,plural_role' ) ); + + $this->assertArrayNotHasKey( 'role', $narrow ); + $this->assertSame( $full['plural_role'], $narrow['plural_role'] ); + } + + /** + * The group type block refines the type list; it must read the resolved + * value rather than the response key. + */ + public function test_types_resolve_without_the_label_being_selected() { + $full = $this->get_first_group(); + $narrow = $this->get_first_group( array( '_fields' => 'id,types' ) ); + + $this->assertArrayNotHasKey( 'group_type_label', $narrow ); + $this->assertSame( $full['types'], $narrow['types'] ); + } + + /** + * A narrow selection must cost measurably fewer queries than no selection. + */ + public function test_narrow_field_selection_runs_fewer_queries() { + self::factory()->group->create_many( 4, array( 'creator_id' => $this->user_id, 'status' => 'public' ) ); + + // Warm anything memoised outside the object cache first. + $this->get_first_group(); + + $full = $this->count_queries( array() ); + $narrow = $this->count_queries( array( '_fields' => 'id,name,slug' ) ); + + $this->assertLessThan( + $full, + $narrow, + sprintf( 'No selection cost %d queries, a narrow selection cost %d.', $full, $narrow ) + ); + } +} diff --git a/tests/phpunit/testcases/media/rest-fields.php b/tests/phpunit/testcases/media/rest-fields.php new file mode 100644 index 00000000000..978776a6335 --- /dev/null +++ b/tests/phpunit/testcases/media/rest-fields.php @@ -0,0 +1,482 @@ +markTestSkipped( 'Media and documents are not both active on this install.' ); + } + + /* + * Booting the REST API brings up every BuddyBoss controller, and some + * of them read the email taxonomy while registering their routes. + */ + if ( ! get_term_by( 'name', 'invites-member-invite', bp_get_email_tax_type() ) ) { + require_once buddypress()->plugin_dir . 'bp-core/admin/bp-core-admin-schema.php'; + bp_core_install_emails(); + } + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + $this->server = $wp_rest_server; + + $this->media_endpoint = new BP_REST_Media_Endpoint(); + $this->document_endpoint = new BP_REST_Document_Endpoint(); + + $base = '/' . bp_rest_namespace() . '/' . bp_rest_version(); + $this->activity_url = $base . '/activity'; + $this->media_url = $base . '/media'; + $this->document_url = $base . '/document'; + + $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $this->user_id ); + + $this->activity_id = self::factory()->activity->create( + array( + 'user_id' => $this->user_id, + 'component' => 'activity', + 'type' => 'activity_update', + 'content' => 'Activity carrying attachments.', + ) + ); + + // The attachment callbacks are gated on profile support being on. + add_filter( 'bp_is_profile_media_support_enabled', '__return_true' ); + add_filter( 'bp_is_profile_document_support_enabled', '__return_true' ); + + $this->media_id = $this->create_media(); + $this->document_id = $this->create_document(); + + // They find the attachments through the activity's metadata, not + // through the attachment's own activity_id. + bp_activity_update_meta( $this->activity_id, 'bp_media_ids', (string) $this->media_id ); + bp_activity_update_meta( $this->activity_id, 'bp_document_ids', (string) $this->document_id ); + wp_cache_delete( $this->activity_id, 'activity_meta' ); + } + + /** + * Drop the REST server so the next test builds a fresh one. + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tearDown(); + } + + /** + * Upload a real file and return its attachment ID. + * + * A genuine upload rather than a stub post: the document controller stats + * the file for `size` and the media controller reads image metadata. + * + * @param string $source Full path to the file to upload. + * + * @return int + */ + protected function create_attachment( $source ) { + return self::factory()->attachment->create_upload_object( $source ); + } + + /** + * Attach a media item to the fixture activity. + * + * @return int + */ + protected function create_media() { + return bp_media_add( + array( + 'attachment_id' => $this->create_attachment( DIR_TESTDATA . '/images/canola.jpg' ), + 'user_id' => $this->user_id, + 'title' => 'Fixture photo', + 'activity_id' => $this->activity_id, + 'privacy' => 'public', + ) + ); + } + + /** + * Attach a document to the fixture activity. + * + * @return int + */ + protected function create_document() { + return bp_document_add( + array( + 'attachment_id' => $this->create_attachment( $this->create_text_file() ), + 'user_id' => $this->user_id, + 'title' => 'Fixture document', + 'activity_id' => $this->activity_id, + 'privacy' => 'public', + ) + ); + } + + /** + * Write a small text file for the document fixture to upload. + * + * @return string Full path. + */ + protected function create_text_file() { + $path = get_temp_dir() . 'bb-rest-fields-fixture.txt'; + file_put_contents( $path, 'attachment fixture' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + + return $path; + } + + /** + * Dispatch a request the way `WP_REST_Server::serve_request()` does. + * + * @param string $route Route. + * @param array $params Request parameters. + * + * @return array + */ + protected function get_data( $route, $params = array() ) { + $request = new WP_REST_Request( 'GET', $route ); + $request->set_param( 'context', 'view' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + $response = $this->server->dispatch( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + + return $response->get_data(); + } + + /** + * Fetch the fixture activity. + * + * @param array $params Request parameters. + * + * @return array + */ + protected function get_activity( $params = array() ) { + $data = $this->get_data( + $this->activity_url, + array_merge( array( 'include' => $this->activity_id ), $params ) + ); + + $this->assertNotEmpty( $data, 'The activity collection came back empty.' ); + + return $data[0]; + } + + /** + * Count how many times a hook fires while a request is dispatched. + * + * @param string $hook Hook name. + * @param string $route Route. + * @param array $params Request parameters. + * + * @return int + */ + protected function count_hook( $hook, $route, $params = array() ) { + $calls = 0; + + $counter = function ( $value ) use ( &$calls ) { + $calls++; + + return $value; + }; + + add_filter( $hook, $counter ); + + try { + $this->get_data( $route, $params ); + } finally { + remove_filter( $hook, $counter ); + } + + return $calls; + } + + /** + * Every key the media controller builds has to be declared in its schema, + * otherwise `get_fields_for_response()` cannot see it and it can never be + * guarded without disappearing from unfiltered responses. + */ + public function test_every_media_field_is_declared_in_the_schema() { + $schema = $this->media_endpoint->get_item_schema(); + $item = $this->get_data( $this->media_url ); + + $this->assertNotEmpty( $item ); + + foreach ( array_keys( $item[0] ) as $field ) { + if ( '_links' === $field || '_embedded' === $field ) { + continue; + } + + $this->assertArrayHasKey( + $field, + $schema['properties'], + sprintf( 'Media returns "%s" but does not declare it.', $field ) + ); + } + } + + /** + * The same for documents. A field the controller returns but does not + * declare cannot be guarded: `get_fields_for_response()` would not list it + * when no `_fields` is sent, and it would vanish from every response. + */ + public function test_every_document_field_is_declared_in_the_schema() { + $schema = $this->document_endpoint->get_item_schema(); + $item = $this->get_data( $this->document_url . '/' . $this->document_id ); + + $this->assertNotEmpty( $item ); + + foreach ( array_keys( $item ) as $field ) { + if ( '_links' === $field || '_embedded' === $field ) { + continue; + } + + $this->assertArrayHasKey( + $field, + $schema['properties'], + sprintf( 'The document controller returns "%s" but does not declare it.', $field ) + ); + } + } + + /** + * A request that sends no `_fields` must return every media field. + */ + public function test_no_field_selection_returns_every_media_field() { + $schema = $this->media_endpoint->get_item_schema(); + $item = $this->get_data( $this->media_url ); + + foreach ( array_keys( $schema['properties'] ) as $field ) { + $this->assertArrayHasKey( $field, $item[0], sprintf( 'Media field "%s" went missing.', $field ) ); + } + } + + /** + * A narrow selection returns exactly the requested media keys. + */ + public function test_narrow_field_selection_returns_only_the_requested_media_keys() { + $item = $this->get_data( $this->media_url, array( '_fields' => 'id,title' ) ); + + $actual = array_keys( $item[0] ); + sort( $actual ); + + $this->assertSame( array( 'id', 'title' ), $actual ); + } + + /** + * An unselected `display_name` must not be resolved. + */ + public function test_media_display_name_is_not_resolved_when_it_is_not_selected() { + $this->assertSame( + 0, + $this->count_hook( 'bp_core_get_user_displayname', $this->media_url, array( '_fields' => 'id,title' ) ) + ); + } + + /** + * ...but a selected one still is. + */ + public function test_media_display_name_is_resolved_when_it_is_selected() { + $this->assertGreaterThan( + 0, + $this->count_hook( 'bp_core_get_user_displayname', $this->media_url, array( '_fields' => 'id,display_name' ) ) + ); + } + + /** + * The same contract for documents. + */ + public function test_narrow_field_selection_returns_only_the_requested_document_keys() { + $item = $this->get_data( $this->document_url . '/' . $this->document_id, array( '_fields' => 'id,title' ) ); + + $actual = array_keys( $item ); + sort( $actual ); + + $this->assertSame( array( 'id', 'title' ), $actual ); + } + + /** + * An unselected document `display_name` must not be resolved. + */ + public function test_document_display_name_is_not_resolved_when_it_is_not_selected() { + $this->assertSame( + 0, + $this->count_hook( 'bp_core_get_user_displayname', $this->document_url . '/' . $this->document_id, array( '_fields' => 'id,title' ) ) + ); + } + + /** + * Nested attachments are returned whole unless `attachment_fields` says + * otherwise: the activity's own `_fields` cannot reach inside a list. + */ + public function test_nested_media_is_whole_without_attachment_fields() { + $activity = $this->get_activity(); + + $this->assertNotEmpty( $activity['bp_media_ids'] ); + + $media = $activity['bp_media_ids'][0]; + + $this->assertArrayHasKey( 'title', $media ); + $this->assertArrayHasKey( 'attachment_data', $media ); + $this->assertArrayHasKey( 'display_name', $media ); + } + + /** + * ...and `attachment_fields` narrows them. + */ + public function test_attachment_fields_narrows_nested_media() { + $activity = $this->get_activity( array( 'attachment_fields' => 'id,title' ) ); + + $this->assertNotEmpty( $activity['bp_media_ids'] ); + + $media = $activity['bp_media_ids'][0]; + + $this->assertArrayHasKey( 'id', $media ); + $this->assertArrayHasKey( 'title', $media ); + $this->assertArrayNotHasKey( 'attachment_data', $media ); + $this->assertArrayNotHasKey( 'display_name', $media ); + } + + /** + * It reaches nested documents too. + */ + public function test_attachment_fields_narrows_nested_documents() { + $activity = $this->get_activity( array( 'attachment_fields' => 'id,title' ) ); + + $this->assertNotEmpty( $activity['bp_documents'] ); + + $document = $activity['bp_documents'][0]; + + $this->assertArrayHasKey( 'title', $document ); + $this->assertArrayNotHasKey( 'msg_preview', $document ); + $this->assertArrayNotHasKey( 'display_name', $document ); + } + + /** + * It applies to the attachments only, never to their parent activity. + */ + public function test_attachment_fields_leaves_the_parent_activity_whole() { + $activity = $this->get_activity( array( 'attachment_fields' => 'id' ) ); + + $this->assertArrayHasKey( 'content', $activity ); + $this->assertArrayHasKey( 'activity_data', $activity ); + } + + /** + * Narrowing the nested attachments has to cost less, not merely return + * less: the guards sit in front of the preview URLs, the download links + * and the per-user permission checks. + * + * `bp_core_get_user_displayname` is deliberately not used as the probe + * here -- it also fires while the activity action is generated, well + * before any attachment is prepared. + */ + public function test_attachment_fields_reduces_the_work_behind_nested_attachments() { + // Warm anything memoised outside the object cache first. + $this->get_activity(); + + $full = $this->count_activity_queries( array() ); + $narrow = $this->count_activity_queries( array( 'attachment_fields' => 'id,title' ) ); + + $this->assertLessThan( + $full, + $narrow, + sprintf( 'No selection cost %d queries, attachment_fields cost %d.', $full, $narrow ) + ); + } + + /** + * Count the queries one activity request costs, from a cold cache. + * + * @param array $params Request parameters. + * + * @return int + */ + protected function count_activity_queries( $params ) { + global $wpdb; + + wp_cache_flush(); + + $before = $wpdb->num_queries; + $this->get_activity( $params ); + + return $wpdb->num_queries - $before; + } + + /** + * `DELETE` answers with an envelope, so its `_fields` names envelope keys. + * The media nested under `previous` must still be built in full. + */ + public function test_delete_returns_a_whole_previous_media() { + $request = new WP_REST_Request( 'DELETE', $this->media_url . '/' . $this->media_id ); + $request->set_param( 'context', 'edit' ); + $request->set_param( '_fields', 'deleted,previous' ); + + $response = $this->server->dispatch( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'previous', $data ); + $this->assertArrayHasKey( 'title', $data['previous'] ); + $this->assertArrayHasKey( 'attachment_data', $data['previous'] ); + } +} diff --git a/tests/phpunit/testcases/members/rest-fields.php b/tests/phpunit/testcases/members/rest-fields.php new file mode 100644 index 00000000000..da39ba06880 --- /dev/null +++ b/tests/phpunit/testcases/members/rest-fields.php @@ -0,0 +1,297 @@ +plugin_dir . 'bp-core/admin/bp-core-admin-schema.php'; + bp_core_install_emails(); + } + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + $this->server = $wp_rest_server; + + $this->endpoint = new BP_REST_Members_Endpoint(); + $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/members'; + + $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $this->user_id ); + } + + /** + * Drop the REST server so the next test builds a fresh one. + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tearDown(); + } + + /** + * Dispatch the way `WP_REST_Server::serve_request()` does. + * + * @param array $params Request parameters. + * + * @return array First member in the collection. + */ + protected function get_first_member( $params = array() ) { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + $response = $this->server->dispatch( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + $data = $response->get_data(); + + $this->assertNotEmpty( $data, 'The members collection came back empty.' ); + + return $data[0]; + } + + /** + * Count how many times a hook fires while a request is dispatched. + * + * @param string $hook Hook name. + * @param array $params Request parameters. + * + * @return int + */ + protected function count_hook( $hook, $params ) { + $calls = 0; + + $counter = function ( $value ) use ( &$calls ) { + $calls++; + + return $value; + }; + + add_filter( $hook, $counter ); + + try { + $this->get_first_member( $params ); + } finally { + remove_filter( $hook, $counter ); + } + + return $calls; + } + + /** + * Count the queries one members request costs, from a cold cache. + * + * @param array $params Request parameters. + * + * @return int + */ + protected function count_queries( $params ) { + global $wpdb; + + wp_cache_flush(); + + $before = $wpdb->num_queries; + $this->get_first_member( $params ); + + return $wpdb->num_queries - $before; + } + + /** + * A request that sends no `_fields` must still return every field the + * `view` context exposes. + */ + public function test_no_field_selection_returns_every_view_field() { + $member = $this->get_first_member(); + $schema = $this->endpoint->get_item_schema(); + + foreach ( $schema['properties'] as $field => $property ) { + if ( empty( $property['context'] ) || ! in_array( 'view', (array) $property['context'], true ) ) { + continue; + } + + $this->assertArrayHasKey( $field, $member, sprintf( 'Member field "%s" went missing.', $field ) ); + } + } + + /** + * Every key the unfiltered response returns has to be declared in the + * schema. An undeclared one cannot be guarded -- it would disappear from + * responses that send no `_fields` at all. + */ + public function test_every_returned_field_is_declared_in_the_schema() { + $schema = $this->endpoint->get_item_schema(); + $item = $this->get_first_member(); + + foreach ( array_keys( $item ) as $field ) { + if ( '_links' === $field || '_embedded' === $field ) { + continue; + } + + $this->assertArrayHasKey( + $field, + $schema['properties'], + sprintf( 'The members controller returns "%s" but does not declare it.', $field ) + ); + } + } + + /** + * A narrow selection returns exactly the requested keys. + */ + public function test_narrow_field_selection_returns_only_the_requested_keys() { + $member = $this->get_first_member( array( '_fields' => 'id,name,user_login' ) ); + + $actual = array_keys( $member ); + sort( $actual ); + + $this->assertSame( array( 'id', 'name', 'user_login' ), $actual ); + } + + /** + * Avatars cost two resolutions per row; an unselected `avatar_urls` must + * not fetch them. + */ + public function test_avatars_are_not_fetched_when_not_selected() { + $this->assertSame( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,name' ) ) ); + } + + /** + * ...but a selected one still does. + */ + public function test_avatars_are_fetched_when_selected() { + $schema = $this->endpoint->get_item_schema(); + + if ( empty( $schema['properties']['avatar_urls'] ) ) { + $this->markTestSkipped( 'Avatars are disabled on this install.' ); + } + + $this->assertGreaterThan( 0, $this->count_hook( 'bp_core_fetch_avatar_url', array( '_fields' => 'id,avatar_urls' ) ) ); + } + + /** + * The xprofile field set is the most expensive thing a member row builds. + * `bp_xprofile_get_groups` fires only from the assembly itself, so it is a + * clean probe -- unlike a raw query count, which the member query pollutes + * by resolving display names through the same tables. + */ + public function test_xprofile_is_not_assembled_when_not_selected() { + if ( ! bp_is_active( 'xprofile' ) ) { + $this->markTestSkipped( 'XProfile is not active on this install.' ); + } + + $this->assertSame( 0, $this->count_hook( 'bp_xprofile_get_groups', array( '_fields' => 'id,name' ) ) ); + } + + /** + * ...but a selected one still is. + */ + public function test_xprofile_is_assembled_when_selected() { + if ( ! bp_is_active( 'xprofile' ) ) { + $this->markTestSkipped( 'XProfile is not active on this install.' ); + } + + $this->assertGreaterThan( 0, $this->count_hook( 'bp_xprofile_get_groups', array( '_fields' => 'id,xprofile' ) ) ); + } + + /** + * A narrow selection must cost measurably fewer queries than no selection. + */ + public function test_narrow_field_selection_runs_fewer_queries() { + self::factory()->user->create_many( 5 ); + + // Warm anything memoised outside the object cache first. + $this->get_first_member(); + + $full = $this->count_queries( array() ); + $narrow = $this->count_queries( array( '_fields' => 'id,name,user_login' ) ); + + $this->assertLessThan( + $full, + $narrow, + sprintf( 'No selection cost %d queries, a narrow selection cost %d.', $full, $narrow ) + ); + } + + /** + * `member_types` is refined after it is first resolved; the refinement + * must not depend on the field having been selected. + */ + public function test_member_types_survive_a_selection_that_names_only_them() { + $member = $this->get_first_member( array( '_fields' => 'id,member_types' ) ); + + $this->assertArrayHasKey( 'member_types', $member ); + $this->assertIsArray( $member['member_types'] ); + } + + /** + * The group membership controller merges `user_data()` into a payload of + * its own, so a selection there must not break either half. + */ + public function test_group_membership_still_merges_member_data() { + if ( ! bp_is_active( 'groups' ) ) { + $this->markTestSkipped( 'Groups are not active on this install.' ); + } + + $group_id = self::factory()->group->create( array( 'creator_id' => $this->user_id ) ); + $member = self::factory()->user->create(); + groups_join_group( $group_id, $member ); + + $request = new WP_REST_Request( 'GET', '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/groups/' . $group_id . '/members' ); + $request->set_param( 'context', 'view' ); + + $response = $this->server->dispatch( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + $data = $response->get_data(); + + $this->assertNotEmpty( $data ); + $this->assertArrayHasKey( 'name', $data[0] ); + $this->assertArrayHasKey( 'is_admin', $data[0] ); + } +} From f35ce3570ef9f0166e019e9973a2e2844a965c17 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Fri, 21 Aug 2026 15:32:03 +0200 Subject: [PATCH 4/8] Extend embed selective fields --- .../class-bp-rest-activity-endpoint.php | 4 + src/bp-core/bp-core-rest-api.php | 343 ++++++++++++ .../class-bp-rest-document-endpoint.php | 4 + .../classes/class-bp-rest-groups-endpoint.php | 4 + .../classes/class-bp-rest-media-endpoint.php | 4 + .../testcases/core/rest-embed-fields.php | 504 ++++++++++++++++++ 6 files changed, 863 insertions(+) create mode 100644 tests/phpunit/testcases/core/rest-embed-fields.php diff --git a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php index 50cefd56c6c..887875dd048 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php @@ -239,6 +239,7 @@ public function register_routes() { * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. * @apiParam {String} [attachment_fields] Comma separated list of fields to build for each returned media, video or document. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. * @apiParam {Array=public,loggedin,onlyme,friends,media} [privacy] Privacy of the activity. * @apiParam {String=activity,group} [pin_type] Show pin activity of feed type. * @apiParam {Number} [topic_id] Limit result set to items with a specific topic ID. @@ -465,6 +466,7 @@ public function get_items_permissions_check( $request ) { * @apiParam {String=stream,threaded,false} [display_comments=false] No comments by default, stream for within stream display, threaded for below each activity item. * @apiParam {String} [comment_fields] Comma separated list of fields to build for each returned comment. * @apiParam {String} [attachment_fields] Comma separated list of fields to build for each returned media, video or document. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_item( $request ) { $activity = $this->get_activity_object( $request ); @@ -3515,6 +3517,8 @@ public function get_collection_params() { 'validate_callback' => 'rest_validate_request_arg', ); + $params['embed_fields'] = bb_rest_embed_fields_param(); + $params['display_comments'] = array( 'description' => __( 'No comments by default, stream for within stream display, threaded for below each activity item.', 'buddyboss' ), 'default' => '', diff --git a/src/bp-core/bp-core-rest-api.php b/src/bp-core/bp-core-rest-api.php index fc2333ac60b..28df171036a 100644 --- a/src/bp-core/bp-core-rest-api.php +++ b/src/bp-core/bp-core-rest-api.php @@ -523,6 +523,349 @@ function bb_rest_set_nested_item_fields( $nested_request, $request, $fields_para return $nested_request; } +/** + * Argument definition for the `embed_fields` request parameter. + * + * Controllers whose items carry embeddable links add this to their collection + * parameters, so that the selection shows up in `OPTIONS` beside the rest. The + * parameter is read off the request wherever it arrives, so it also works on + * the routes that declare no arguments of their own. + * + * @since BuddyBoss [BBVERSION] + * + * @return array The argument definition. + */ +function bb_rest_embed_fields_param() { + return array( + 'description' => __( 'Limit the items returned under `_embedded` to a comma separated list of fields. Send one list for every relation -- `embed_fields=id,name` -- or one list per relation -- `embed_fields[user]=id,name` -- where `*` is the selection the relations without one of their own fall back to. The item\'s own `_fields` cannot reach them, because WordPress builds an embedded item from its link alone.', 'buddyboss' ), + 'default' => '', + 'type' => array( 'string', 'object' ), + 'sanitize_callback' => 'bb_rest_parse_embed_fields', + ); +} + +/** + * Parse the field selection a request sent for the items it has embedded. + * + * The selection takes one of two shapes. A bare list -- `embed_fields=id,name`, + * or the `embed_fields[]=id&embed_fields[]=name` a client spells it with just + * as readily -- is the selection every embeddable relation falls back to, and + * is held here under `*`. A list per relation -- `embed_fields[user]=id,name` + * -- names the relations it narrows, leaves the rest whole, and may carry a + * `*` of its own for the relations it does not name. + * + * Parsing a selection that has already been parsed returns it unchanged, so a + * controller is free to sanitise the parameter with this and the value still + * reads the same further down. + * + * @since BuddyBoss [BBVERSION] + * + * @param array|string $embed_fields Raw value of the `embed_fields` parameter. + * + * @return array Comma separated field lists, keyed by link relation. + */ +function bb_rest_parse_embed_fields( $embed_fields ) { + if ( empty( $embed_fields ) ) { + return array(); + } + + // Anything that names no relation is the selection for all of them. + if ( ! is_array( $embed_fields ) || wp_is_numeric_array( $embed_fields ) ) { + $embed_fields = array( '*' => $embed_fields ); + } + + $selections = array(); + + foreach ( $embed_fields as $rel => $fields ) { + // A list arrives as a string or, one field per key, as an array. + if ( ! is_scalar( $fields ) && ! is_array( $fields ) ) { + continue; + } + + $fields = array_filter( array_map( 'sanitize_text_field', wp_parse_list( $fields ) ), 'strlen' ); + + if ( empty( $fields ) ) { + continue; + } + + $selections[ $rel ] = implode( ',', $fields ); + } + + return $selections; +} + +/** + * Hold the selections the items embedded in the current response are built with. + * + * `WP_REST_Server::embed_links()` builds an embedded item from its link alone: + * `WP_REST_Request::from_url()` is handed the `href` and nothing else, and the + * request that asked for the embed is never consulted. The selection therefore + * has to be waiting for it, keyed by that same `href`. + * + * @since BuddyBoss [BBVERSION] + * + * @param array|null $selections Optional. Selections to hold, keyed by `href`. + * Null reads what is held. Default null. + * + * @return array Comma separated field lists, keyed by `href`. + */ +function bb_rest_held_embed_fields( $selections = null ) { + static $held = array(); + + if ( is_array( $selections ) ) { + $held = $selections; + } + + return $held; +} + +/** + * Remember a request built for an embedded item, or recognise one. + * + * The mark cannot travel on the request: a parameter or a header is the + * client's to send, and a request that arrived wearing one would have its links + * stripped and would slip past the reset every request of its own performs. + * The requests are held here by identity instead, in the one structure PHP has + * for the purpose. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request|null $request Optional. Request to recognise, or to + * remember when `$remember` is set. + * Anything else forgets every request + * held. Default null. + * @param bool $remember Optional. Whether to remember the + * request rather than recognise it. + * Default false. + * + * @return bool Whether the request is one built for an embedded item. + */ +function bb_rest_embedded_request( $request = null, $remember = false ) { + static $embedded = null; + + if ( ! $request instanceof WP_REST_Request || ! $embedded instanceof SplObjectStorage ) { + $embedded = new SplObjectStorage(); + } + + if ( ! $request instanceof WP_REST_Request ) { + return false; + } + + if ( $remember ) { + $embedded->attach( $request ); + + return true; + } + + return $embedded->contains( $request ); +} + +/** + * Map the embeddable links of a response to the selection their relation asked for. + * + * A collection carries the links of each item inside its data, a single item + * carries them on the response, and the two spell an attribute differently: + * the links of an item have `embeddable` beside `href`, the links of a + * response keep it under `attributes`. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Response $response Response the links belong to. + * @param array $selections Comma separated field lists, keyed by link relation. + * + * @return array Comma separated field lists, keyed by `href`. + */ +function bb_rest_map_embed_fields_to_links( $response, $selections ) { + $data = $response->get_data(); + $link_sets = array( $response->get_links() ); + + if ( is_array( $data ) ) { + if ( wp_is_numeric_array( $data ) ) { + foreach ( $data as $item ) { + if ( is_array( $item ) && ! empty( $item['_links'] ) && is_array( $item['_links'] ) ) { + $link_sets[] = $item['_links']; + } + } + } elseif ( ! empty( $data['_links'] ) && is_array( $data['_links'] ) ) { + $link_sets[] = $data['_links']; + } + } + + $map = array(); + $disputed = array(); + + foreach ( $link_sets as $links ) { + foreach ( (array) $links as $rel => $rel_links ) { + if ( isset( $selections[ $rel ] ) ) { + $fields = $selections[ $rel ]; + } elseif ( isset( $selections['*'] ) ) { + $fields = $selections['*']; + } else { + continue; + } + + foreach ( (array) $rel_links as $link ) { + $attributes = isset( $link['attributes'] ) ? $link['attributes'] : $link; + + if ( empty( $link['href'] ) || empty( $attributes['embeddable'] ) ) { + continue; + } + + /* + * WordPress builds and caches an embedded item once per + * `href`, so two relations pointing at the same URL cannot be + * answered with two selections. Rather than let whichever was + * read last decide, neither does: the item is built whole, + * which is the only answer that shortchanges no one. + */ + if ( isset( $map[ $link['href'] ] ) && $map[ $link['href'] ] !== $fields ) { + $disputed[ $link['href'] ] = true; + } + + $map[ $link['href'] ] = $fields; + } + } + } + + return array_diff_key( $map, $disputed ); +} + +/** + * Hold the field selection the items of a response are to be embedded with. + * + * The response is not touched. All this leaves behind is the selection each + * embeddable link is owed, which `bb_rest_narrow_embedded_request()` picks up + * once the server starts building the embeds. + * + * The same callback answers the embedded items themselves, since WordPress + * runs them through this filter too. An item narrowed to a selection that does + * not name `_links` is answered without them, the way `_fields` answers the + * items of a collection. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Response $response Result to send to the client. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return WP_REST_Response The response, untouched but for the links of a narrowed item. + */ +function bb_rest_prepare_embedded_fields( $response, $server, $request ) { + if ( ! $response instanceof WP_REST_Response || ! $request instanceof WP_REST_Request ) { + return $response; + } + + // One of the embedded items, on its way back to the item that asked for it. + if ( bb_rest_embedded_request( $request ) ) { + $fields = $request->get_param( '_fields' ); + + if ( ! empty( $fields ) && ! rest_is_field_included( '_links', wp_parse_list( $fields ) ) ) { + foreach ( array_keys( $response->get_links() ) as $rel ) { + $response->remove_link( $rel ); + } + } + + return $response; + } + + // A request of its own: nothing an earlier one held is still owed to it. + bb_rest_forget_embed_fields(); + + if ( 0 !== strpos( ltrim( $request->get_route(), '/' ), bp_rest_namespace() . '/' ) ) { + return $response; + } + + $selections = bb_rest_parse_embed_fields( $request->get_param( 'embed_fields' ) ); + + if ( empty( $selections ) ) { + return $response; + } + + bb_rest_held_embed_fields( bb_rest_map_embed_fields_to_links( $response, $selections ) ); + + return $response; +} +add_filter( 'rest_post_dispatch', 'bb_rest_prepare_embedded_fields', 11, 3 ); + +/** + * Carry the selection an embedded item is owed onto the request that builds it. + * + * This is the only place the selection can reach: WordPress generates the + * request from the link's `href`, so the `href` is all there is to recognise + * it by. The request is remembered as well, so that its response is answered + * as an embedded item rather than as a request of its own. + * + * The selection is set as a query parameter, since dispatch replaces the URL + * parameters of a request wholesale once it matches a route. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request|false $request Generated request object, or false if + * the URL could not be parsed. + * @param string $url URL the request was generated from. + * + * @return WP_REST_Request|false The request, narrowed to what its relation asked for. + */ +function bb_rest_narrow_embedded_request( $request, $url ) { + $held = bb_rest_held_embed_fields(); + + if ( empty( $held ) || ! $request instanceof WP_REST_Request ) { + return $request; + } + + /* + * Every request built while a selection is held is one of the embedded + * items, including the ones whose relation asked for nothing: the response + * has to be recognised either way, or it would be taken for a request of + * its own and would clear what the rest of the embeds are still owed. + */ + bb_rest_embedded_request( $request, true ); + + if ( isset( $held[ $url ] ) ) { + $query = $request->get_query_params(); + + $query['_fields'] = $held[ $url ]; + + $request->set_query_params( $query ); + } + + return $request; +} +add_filter( 'rest_request_from_url', 'bb_rest_narrow_embedded_request', 10, 2 ); + +/** + * Forget what a response owed the items embedded in it. + * + * The next request of its own clears this anyway, but not every process serves + * exactly one: `/batch/v1` dispatches several, and WP-CLI and cron dispatch + * whatever they please. Letting go of the selection the moment the response it + * belongs to has been assembled keeps it from reaching any of them. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $result Response data to send to the client. + * + * @return array The response data, untouched. + */ +function bb_rest_forget_embedded_fields( $result ) { + bb_rest_forget_embed_fields(); + + return $result; +} +add_filter( 'rest_pre_echo_response', 'bb_rest_forget_embedded_fields' ); + +/** + * Let go of every selection and every embedded request being held. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ +function bb_rest_forget_embed_fields() { + bb_rest_held_embed_fields( array() ); + bb_rest_embedded_request(); +} + /** * Set the global variable for the REST request. * diff --git a/src/bp-document/classes/class-bp-rest-document-endpoint.php b/src/bp-document/classes/class-bp-rest-document-endpoint.php index 8c03c04176f..20ba30b693d 100644 --- a/src/bp-document/classes/class-bp-rest-document-endpoint.php +++ b/src/bp-document/classes/class-bp-rest-document-endpoint.php @@ -275,6 +275,7 @@ public function upload_item_permissions_check( $request ) { * @apiParam {Array} [include] Ensure result set includes specific IDs. * @apiParam {String=both,document,folder} [type=both] Ensure result set includes specific document type. * @apiParam {Boolean} [count_total=true] Show total count or not. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_items( $request ) { $args = array( @@ -452,6 +453,7 @@ public function get_items_permissions_check( $request ) { * @apiVersion 1.0.0 * @apiPermission LoggedInUser if the site is in Private Network. * @apiParam {Number} id A unique numeric ID for the document. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_item( $request ) { @@ -1900,6 +1902,8 @@ public function get_item_schema() { public function get_collection_params() { $params = parent::get_collection_params(); + $params['embed_fields'] = bb_rest_embed_fields_param(); + $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'buddyboss' ), 'default' => 'asc', diff --git a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php index f4715bc82f9..4e2b71f91f3 100644 --- a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php +++ b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php @@ -130,6 +130,7 @@ public function register_routes() { * @apiParam {Boolean} [show_hidden] Whether results should include hidden Groups. * @apiParam {String=all,personal} [scope=all] Limit result set to items with a specific scope. * @apiParam {Boolean} [can_post] Fetch current users groups which can post activity in it. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_items( $request ) { $args = array( @@ -297,6 +298,7 @@ public function get_items_permissions_check( $request ) { * @apiVersion 1.0.0 * @apiPermission LoggedInUser if the site is in Private Network. * @apiParam {Number} id A unique numeric ID for the Group. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_item( $request ) { $group = $this->get_group_object( $request ); @@ -1892,6 +1894,8 @@ public function get_collection_params() { $params = parent::get_collection_params(); $params['context']['default'] = 'view'; + $params['embed_fields'] = bb_rest_embed_fields_param(); + $params['type'] = array( 'description' => __( 'Shorthand for certain orderby/order combinations.', 'buddyboss' ), 'default' => 'active', diff --git a/src/bp-media/classes/class-bp-rest-media-endpoint.php b/src/bp-media/classes/class-bp-rest-media-endpoint.php index 5615adee4e4..6a815bcf6d2 100644 --- a/src/bp-media/classes/class-bp-rest-media-endpoint.php +++ b/src/bp-media/classes/class-bp-rest-media-endpoint.php @@ -170,6 +170,7 @@ public function register_routes() { * @apiParam {Array} [exclude] Ensure result set excludes specific IDs. * @apiParam {Array} [include] Ensure result set includes specific IDs. * @apiParam {Boolean} [count_total=true] Show total count or not. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_items( $request ) { $args = array( @@ -343,6 +344,7 @@ public function get_items_permissions_check( $request ) { * @apiVersion 1.0.0 * @apiPermission LoggedInUser if the site is in Private Network. * @apiParam {Number} id A unique numeric ID for the media photo. + * @apiParam {String|Object} [embed_fields] Comma separated list of fields to build for each item embedded with `_embed`, either for every relation or one relation at a time. */ public function get_item( $request ) { @@ -1862,6 +1864,8 @@ public function get_item_schema() { public function get_collection_params() { $params = parent::get_collection_params(); + $params['embed_fields'] = bb_rest_embed_fields_param(); + $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'buddyboss' ), 'default' => 'desc', diff --git a/tests/phpunit/testcases/core/rest-embed-fields.php b/tests/phpunit/testcases/core/rest-embed-fields.php new file mode 100644 index 00000000000..fa0e0009e3f --- /dev/null +++ b/tests/phpunit/testcases/core/rest-embed-fields.php @@ -0,0 +1,504 @@ +plugin_dir . 'bp-core/admin/bp-core-admin-schema.php'; + bp_core_install_emails(); + } + + /* + * The test case restores the hook snapshot after every test, which + * unregisters the callbacks `rest_api_init` added -- including + * `rest_filter_response_fields()`. Rebuild the server per test so the + * dispatch pipeline is the one a real request goes through. + */ + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + + $this->server = $wp_rest_server; + $this->endpoint = new BP_REST_Activity_Endpoint(); + $this->endpoint_url = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/activity'; + + $this->user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + $this->group_id = self::factory()->group->create( + array( + 'creator_id' => $this->user_id, + 'status' => 'public', + ) + ); + + $this->activity_id = self::factory()->activity->create( + array( + 'user_id' => $this->user_id, + 'component' => 'groups', + 'item_id' => $this->group_id, + 'type' => 'activity_update', + 'content' => 'Embedded field selection fixture.', + ) + ); + + wp_set_current_user( $this->user_id ); + } + + /** + * Drop the REST server so the next test builds a fresh one. + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tearDown(); + } + + /** + * Dispatch a request the way `WP_REST_Server::serve_request()` does. + * + * @param WP_REST_Request $request Request to dispatch. + * + * @return WP_REST_Response + */ + protected function dispatch( $request ) { + $response = $this->server->dispatch( $request ); + + return apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $this->server, $request ); + } + + /** + * Fetch the first activity of the collection with its links embedded. + * + * @param array $params Request parameters. + * @param array|true $rels Relations to embed. + * + * @return array The first item of the collection, embeds included. + */ + protected function get_first_item( $params = array(), $rels = true ) { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + $request->set_param( '_fields', 'id,_links,_embedded' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + $response = $this->dispatch( $request ); + $data = $this->server->response_to_data( $response, $rels ); + + $this->assertNotEmpty( $data, 'The activity collection came back empty.' ); + $this->assertArrayHasKey( '_embedded', $data[0], 'The activity came back with nothing embedded.' ); + + return $data[0]; + } + + /** + * Read one embedded item off an activity. + * + * @param array $item Activity item. + * @param string $rel Link relation. + * + * @return array The embedded item. + */ + protected function get_embedded( $item, $rel ) { + $this->assertArrayHasKey( $rel, $item['_embedded'], "Nothing was embedded for the `{$rel}` relation." ); + $this->assertNotEmpty( $item['_embedded'][ $rel ] ); + + return $item['_embedded'][ $rel ][0]; + } + + /** + * Count how many times a hook fires while a collection is embedded. + * + * @param string $hook Hook name. + * @param array $params Request parameters. + * @param array|true $rels Relations to embed. + * + * @return int + */ + protected function count_hook( $hook, $params, $rels = true ) { + $calls = 0; + + $counter = function ( $value ) use ( &$calls ) { + $calls++; + + return $value; + }; + + add_filter( $hook, $counter ); + + try { + $this->get_first_item( $params, $rels ); + } finally { + remove_filter( $hook, $counter ); + } + + return $calls; + } + + /** + * Without a selection the embedded items stay exactly as they were. + */ + public function test_embedded_items_are_whole_without_a_selection() { + $item = $this->get_first_item(); + + $user = $this->get_embedded( $item, 'user' ); + + $this->assertArrayHasKey( 'id', $user ); + $this->assertArrayHasKey( 'mention_name', $user ); + $this->assertArrayHasKey( 'link', $user ); + $this->assertArrayHasKey( '_links', $user ); + + $group = $this->get_embedded( $item, 'group' ); + + $this->assertArrayHasKey( 'name', $group ); + $this->assertArrayHasKey( 'status', $group ); + } + + /** + * One list narrows every embedded relation. + */ + public function test_embed_fields_narrows_every_relation() { + $item = $this->get_first_item( array( 'embed_fields' => 'id,name' ) ); + + $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) ); + $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'group' ) ) ); + } + + /** + * A list per relation narrows only the relations it names. + */ + public function test_embed_fields_narrows_one_relation_at_a_time() { + $item = $this->get_first_item( array( 'embed_fields' => array( 'user' => 'id,name' ) ) ); + + $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) ); + + $group = $this->get_embedded( $item, 'group' ); + + $this->assertArrayHasKey( 'status', $group ); + $this->assertArrayHasKey( '_links', $group ); + } + + /** + * `*` is the selection every relation without one of its own falls back to. + */ + public function test_embed_fields_falls_back_to_the_default_selection() { + $item = $this->get_first_item( + array( + 'embed_fields' => array( + '*' => 'id', + 'user' => 'id,name', + ), + ) + ); + + $this->assertSame( array( 'id', 'name' ), array_keys( $this->get_embedded( $item, 'user' ) ) ); + $this->assertSame( array( 'id' ), array_keys( $this->get_embedded( $item, 'group' ) ) ); + } + + /** + * The selection reaches the embedded items only, never their parent. + */ + public function test_embed_fields_leaves_the_outer_item_whole() { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + $request->set_param( 'embed_fields', 'id,name' ); + + $data = $this->dispatch( $request )->get_data(); + + $this->assertNotEmpty( $data ); + $this->assertArrayHasKey( 'content', $data[0] ); + $this->assertArrayHasKey( 'activity_data', $data[0] ); + } + + /** + * A selection that does not name `_links` is answered without them, the + * same way `_fields` answers a collection. + */ + public function test_embed_fields_drops_the_links_it_was_not_asked_for() { + $item = $this->get_first_item( array( 'embed_fields' => 'id,name' ) ); + + $this->assertArrayNotHasKey( '_links', $this->get_embedded( $item, 'user' ) ); + } + + /** + * ...and a selection that names them keeps them. + */ + public function test_embed_fields_keeps_the_links_it_was_asked_for() { + $item = $this->get_first_item( array( 'embed_fields' => 'id,_links' ) ); + + $user = $this->get_embedded( $item, 'user' ); + + $this->assertArrayHasKey( '_links', $user ); + $this->assertArrayNotHasKey( 'name', $user ); + } + + /** + * A single item keeps its links on the response rather than in its data, + * and the selection has to find them there too. + */ + public function test_embed_fields_narrows_a_single_item_response() { + $request = new WP_REST_Request( 'GET', $this->endpoint_url . '/' . $this->activity_id ); + $request->set_param( 'context', 'view' ); + $request->set_param( 'embed_fields', 'id,name' ); + + $response = $this->dispatch( $request ); + $data = $this->server->response_to_data( $response, true ); + + $this->assertArrayHasKey( '_embedded', $data ); + $this->assertSame( array( 'id', 'name' ), array_keys( $data['_embedded']['user'][0] ) ); + + // The activity itself was asked for nothing, so it comes back whole. + $this->assertArrayHasKey( 'content', $data ); + } + + /** + * The selection stands on its own: the caller does not have to send a + * `_fields` of its own for it to reach the embedded items. + */ + public function test_embed_fields_needs_no_field_selection_of_its_own() { + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'context', 'view' ); + $request->set_param( 'embed_fields', 'id,name' ); + + $response = $this->dispatch( $request ); + $data = $this->server->response_to_data( $response, array( 'user' ) ); + + $this->assertNotEmpty( $data ); + $this->assertArrayHasKey( 'content', $data[0] ); + $this->assertSame( array( 'id', 'name' ), array_keys( $data[0]['_embedded']['user'][0] ) ); + } + + /** + * An unselected field of an embedded item must not be built either. + */ + public function test_embed_fields_skips_the_work_behind_an_unselected_field() { + $this->assertSame( + 0, + $this->count_hook( + 'bp_core_fetch_avatar_url', + array( 'embed_fields' => 'id,name' ), + array( 'user' ) + ) + ); + } + + /** + * ...and a selected one still is. + */ + public function test_embed_fields_does_the_work_behind_a_selected_field() { + $this->assertGreaterThan( + 0, + $this->count_hook( + 'bp_core_fetch_avatar_url', + array( 'embed_fields' => 'id,name,avatar_urls' ), + array( 'user' ) + ) + ); + } + + /** + * Every BuddyBoss controller answers a single item the way it answers a + * member of a collection, with the links flattened into the data. A + * controller that keeps them on the response instead -- the shape + * `WP_REST_Controller` produces -- has to be read just the same. + */ + public function test_links_kept_on_the_response_are_mapped_too() { + $response = new WP_REST_Response( array( 'id' => 1 ) ); + + $response->add_link( 'self', 'https://example.org/self' ); + $response->add_link( 'user', 'https://example.org/user', array( 'embeddable' => true ) ); + + $map = bb_rest_map_embed_fields_to_links( $response, array( '*' => 'id,name' ) ); + + $this->assertSame( array( 'https://example.org/user' => 'id,name' ), $map ); + } + + /** + * A relation with no selection of its own, and no `*` to fall back to, is + * left out of the map entirely, so its item is built in full. + */ + public function test_a_relation_without_a_selection_is_not_mapped() { + $response = new WP_REST_Response( array( 'id' => 1 ) ); + + $response->add_link( 'user', 'https://example.org/user', array( 'embeddable' => true ) ); + + $this->assertSame( array(), bb_rest_map_embed_fields_to_links( $response, array( 'group' => 'id' ) ) ); + } + + /** + * Parsing a parsed selection returns it unchanged, so that a controller + * can sanitise the parameter with the parser and still read it later. + */ + public function test_parsing_a_selection_is_idempotent() { + $once = bb_rest_parse_embed_fields( ' id , name ' ); + + $this->assertSame( array( '*' => 'id,name' ), $once ); + $this->assertSame( $once, bb_rest_parse_embed_fields( $once ) ); + } + + /** + * `embed_fields[]=id,name` is a spelling a client reaches for as readily + * as the bare string, and it has to mean the same thing rather than + * quietly resolving to no selection at all. + */ + public function test_a_list_without_relations_is_read_as_one_selection() { + $this->assertSame( array( '*' => 'id,name' ), bb_rest_parse_embed_fields( array( 'id,name' ) ) ); + $this->assertSame( array( '*' => 'id,name' ), bb_rest_parse_embed_fields( array( 'id', 'name' ) ) ); + } + + /** + * WordPress builds and caches an embedded item once per `href`, so two + * relations sharing a URL cannot be answered with two selections. Neither + * may be applied, or one of them silently loses fields it asked for. + */ + public function test_a_shared_href_with_conflicting_selections_is_left_whole() { + $response = new WP_REST_Response( array( 'id' => 1 ) ); + + $response->add_link( 'user', 'https://example.org/members/5', array( 'embeddable' => true ) ); + $response->add_link( 'author', 'https://example.org/members/5', array( 'embeddable' => true ) ); + + $map = bb_rest_map_embed_fields_to_links( + $response, + array( + 'user' => 'id,name', + 'author' => 'avatar_urls', + ) + ); + + $this->assertSame( array(), $map ); + } + + /** + * ...but relations that agree are still narrowed. + */ + public function test_a_shared_href_with_one_selection_is_narrowed() { + $response = new WP_REST_Response( array( 'id' => 1 ) ); + + $response->add_link( 'user', 'https://example.org/members/5', array( 'embeddable' => true ) ); + $response->add_link( 'author', 'https://example.org/members/5', array( 'embeddable' => true ) ); + + $map = bb_rest_map_embed_fields_to_links( $response, array( '*' => 'id,name' ) ); + + $this->assertSame( array( 'https://example.org/members/5' => 'id,name' ), $map ); + } + + /** + * An embedded item is recognised by identity, never by anything the client + * can send. A request that dresses itself up as one keeps its links, and + * still clears what an earlier response left held. + */ + public function test_a_client_cannot_pass_itself_off_as_an_embedded_item() { + bb_rest_held_embed_fields( array( 'https://example.org/members/5' => 'id' ) ); + + $request = new WP_REST_Request( 'GET', $this->endpoint_url ); + $request->set_param( 'bb_embedded_item', true ); + $request->set_param( '_fields', 'id' ); + + $response = new WP_REST_Response( array( 'id' => 1 ) ); + $response->add_link( 'self', 'https://example.org/activity/1' ); + + bb_rest_prepare_embedded_fields( $response, $this->server, $request ); + + $this->assertArrayHasKey( 'self', $response->get_links() ); + $this->assertSame( array(), bb_rest_held_embed_fields() ); + } + + /** + * Controllers whose items carry embeddable links. + * + * @return array + */ + public static function embeddable_controller_provider() { + return array( + array( 'BP_REST_Activity_Endpoint' ), + array( 'BP_REST_Groups_Endpoint' ), + array( 'BP_REST_Media_Endpoint' ), + array( 'BP_REST_Document_Endpoint' ), + ); + } + + /** + * The parameter is declared, so that it shows up in `OPTIONS`. + * + * @dataProvider embeddable_controller_provider + * + * @param string $controller Controller class name. + */ + public function test_embed_fields_is_a_collection_parameter( $controller ) { + if ( ! class_exists( $controller ) ) { + $this->markTestSkipped( "{$controller} is not available on this install." ); + } + + $endpoint = new $controller(); + + $this->assertArrayHasKey( 'embed_fields', $endpoint->get_collection_params() ); + } +} From 99676af79b2bbab22f161a35fd6bfd13ba1828ec Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Fri, 21 Aug 2026 17:38:46 +0200 Subject: [PATCH 5/8] Fix pipeline --- .github/workflows/npm-grunt.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-grunt.yml b/.github/workflows/npm-grunt.yml index 89c0593b62e..717a1908948 100644 --- a/.github/workflows/npm-grunt.yml +++ b/.github/workflows/npm-grunt.yml @@ -38,7 +38,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential python3 gcc g++ make + sudo apt-get install -y build-essential python3 gcc g++ make php-cli php-mbstring - name: Setup Python 3 run: | @@ -80,10 +80,35 @@ jobs: - name: Install remaining dependencies run: npm install --legacy-peer-deps --no-audit --force + # The @testing-library packages are declared in package.json but are absent + # from package-lock.json, which is still lockfileVersion 1 and predates + # them. The install above therefore does not reliably leave them behind -- + # on Node 14 / npm 6 it does not -- and `grunt checkDependencies` aborts the + # whole build over three packages no Grunt task even uses. Installing them + # last, straight from the ranges package.json declares and without writing + # to it, makes the check pass on what is actually there. + # Remove this once package-lock.json is regenerated. + - name: Install Jest testing libraries + run: | + PACKAGES=$(node -e "const d = require('./package.json').devDependencies; console.log( Object.keys( d ).filter( k => k.startsWith('@testing-library/') ).map( k => k + '@' + d[ k ] ).join(' ') )") + echo "Installing: $PACKAGES" + npm install --no-save --legacy-peer-deps --no-audit --force $PACKAGES + + # `grunt makepot` shells out to WP-CLI (`wp i18n make-pot`) since the POT + # generation moved off grunt-wp-i18n. The runner does not ship WP-CLI, so + # without this the task exits 127 and takes the build down with it. + - name: Install WP-CLI + run: | + curl -sSL -o wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + chmod +x wp-cli.phar + sudo mv wp-cli.phar /usr/local/bin/wp + wp --info + - name: Display versions run: | npm -v node -v + wp --version - name: Run Grunt run: grunt --verbose From 9065e7961cdfcf7929981623631f8af9ee1941d9 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Tue, 25 Aug 2026 15:59:29 +0200 Subject: [PATCH 6/8] Measure and optimise --- src/bb-perf-lab/assets/perf-lab.css | 83 ++ src/bb-perf-lab/assets/perf-lab.js | 387 +++++ src/bb-perf-lab/bb-perf-lab.php | 117 ++ .../classes/class-bb-perf-lab-admin.php | 682 +++++++++ .../classes/class-bb-perf-lab-bench.php | 363 +++++ .../classes/class-bb-perf-lab-monitor.php | 414 ++++++ .../classes/class-bb-perf-lab-seeder.php | 1309 +++++++++++++++++ .../class-bp-rest-activity-endpoint.php | 140 +- src/bp-loader.php | 3 + .../testcases/activity/rest-fields.php | 111 ++ 10 files changed, 3574 insertions(+), 35 deletions(-) create mode 100644 src/bb-perf-lab/assets/perf-lab.css create mode 100644 src/bb-perf-lab/assets/perf-lab.js create mode 100644 src/bb-perf-lab/bb-perf-lab.php create mode 100644 src/bb-perf-lab/classes/class-bb-perf-lab-admin.php create mode 100644 src/bb-perf-lab/classes/class-bb-perf-lab-bench.php create mode 100644 src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php create mode 100644 src/bb-perf-lab/classes/class-bb-perf-lab-seeder.php diff --git a/src/bb-perf-lab/assets/perf-lab.css b/src/bb-perf-lab/assets/perf-lab.css new file mode 100644 index 00000000000..a8c0007d9c1 --- /dev/null +++ b/src/bb-perf-lab/assets/perf-lab.css @@ -0,0 +1,83 @@ +/** + * Performance Lab -- admin screen styling. + * + * TEMPORARY diagnostic tooling. See `bb-perf-lab.php` for removal instructions. + * + * @since BuddyBoss [BBVERSION] + */ + +.bb-perf-lab .bb-perf-panel { + margin-top: 1.5em; +} + +.bb-perf-lab .bb-perf-status { + margin-left: 8px; + color: #646970; +} + +.bb-perf-lab .bb-perf-bar { + background: #dcdcde; + border-radius: 3px; + height: 18px; + margin: 12px 0 6px; + max-width: 640px; + overflow: hidden; +} + +.bb-perf-lab .bb-perf-bar span { + background: #2271b1; + display: block; + height: 100%; + transition: width 0.2s ease; + width: 0; +} + +.bb-perf-lab .bb-perf-result { + margin-top: 12px; + max-width: 860px; +} + +.bb-perf-lab .bb-perf-result th[scope="row"] { + width: 200px; +} + +.bb-perf-lab .bb-perf-good { + color: #007017; + font-weight: 600; +} + +.bb-perf-lab .bb-perf-bad { + color: #b32d2e; + font-weight: 600; +} + +.bb-perf-lab .bb-perf-verdict { + background: #fff; + border-left: 4px solid #2271b1; + margin-top: 16px; + max-width: 860px; + padding: 4px 16px; +} + +.bb-perf-lab .bb-perf-notes { + list-style: disc; + margin-left: 20px; +} + +.bb-perf-lab .bb-perf-log code { + font-size: 11px; + word-break: break-all; +} + +.bb-perf-lab .bb-perf-log tr.is-selective td:first-child { + border-left: 3px solid #007017; +} + +.bb-perf-lab .bb-perf-slow pre { + background: #f6f7f7; + font-size: 11px; + max-height: 320px; + overflow: auto; + padding: 8px; + white-space: pre-wrap; +} diff --git a/src/bb-perf-lab/assets/perf-lab.js b/src/bb-perf-lab/assets/perf-lab.js new file mode 100644 index 00000000000..623d6e06f33 --- /dev/null +++ b/src/bb-perf-lab/assets/perf-lab.js @@ -0,0 +1,387 @@ +/** + * Performance Lab -- admin screen behaviour. + * + * TEMPORARY diagnostic tooling. See `bb-perf-lab.php` for removal instructions. + * + * @since BuddyBoss [BBVERSION] + */ +( function () { + 'use strict'; + + /** + * Post to one of the lab's AJAX endpoints. + * + * @since BuddyBoss [BBVERSION] + * + * @param {string} action Action suffix, e.g. 'bench'. + * @param {Object} data Payload. + * @param {Function} done Called with ( error, payload ). + * + * @return {void} + */ + function post( action, data, done ) { + var body = new FormData(); + var key; + + body.append( 'action', 'bb_perf_lab_' + action ); + body.append( 'nonce', window.bbPerfLab.nonce ); + + for ( key in data ) { + if ( Object.prototype.hasOwnProperty.call( data, key ) ) { + body.append( key, data[ key ] ); + } + } + + window.fetch( window.bbPerfLab.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body + } ).then( function ( response ) { + return response.json(); + } ).then( function ( payload ) { + if ( ! payload || ! payload.success ) { + done( ( payload && payload.data && payload.data.message ) || 'Request failed.', null ); + return; + } + + done( null, payload.data ); + } ).catch( function ( error ) { + done( error.message || 'Request failed.', null ); + } ); + } + + /** + * Shorthand for getElementById. + * + * @since BuddyBoss [BBVERSION] + * + * @param {string} id Element id. + * + * @return {HTMLElement|null} The element. + */ + function el( id ) { + return document.getElementById( id ); + } + + /** + * Format a number for display. + * + * @since BuddyBoss [BBVERSION] + * + * @param {number} value Value. + * @param {number} decimals Decimal places. + * + * @return {string} Formatted value. + */ + function num( value, decimals ) { + return Number( value || 0 ).toFixed( undefined === decimals ? 1 : decimals ); + } + + /** + * Escape text destined for markup. + * + * The benchmark's notes and error messages are server-authored, but an error + * can carry a route the operator typed, so nothing goes into markup unescaped. + * + * @since BuddyBoss [BBVERSION] + * + * @param {string} text Text to escape. + * + * @return {string} Escaped text. + */ + function esc( text ) { + var node = document.createElement( 'span' ); + + node.textContent = String( text === undefined || text === null ? '' : text ); + + return node.innerHTML; + } + + /* ----------------------------------------------------------------- + * Tabs + * -------------------------------------------------------------- */ + + document.querySelectorAll( '.nav-tab[data-tab]' ).forEach( function ( tab ) { + tab.addEventListener( 'click', function ( event ) { + event.preventDefault(); + + document.querySelectorAll( '.nav-tab[data-tab]' ).forEach( function ( other ) { + other.classList.remove( 'nav-tab-active' ); + } ); + + tab.classList.add( 'nav-tab-active' ); + + document.querySelectorAll( '.bb-perf-panel' ).forEach( function ( panel ) { + panel.hidden = panel.getAttribute( 'data-panel' ) !== tab.getAttribute( 'data-tab' ); + } ); + } ); + } ); + + /* ----------------------------------------------------------------- + * Benchmark + * -------------------------------------------------------------- */ + + /** + * Render one metric row of the comparison table. + * + * @since BuddyBoss [BBVERSION] + * + * @param {string} label Row label. + * @param {Object} arms The three arms. + * @param {string} metric Metric key. + * @param {number} decimals Decimal places. + * + * @return {string} Table row markup. + */ + function metricRow( label, arms, metric, decimals ) { + var full = arms.full[ metric ].median; + var sel = arms.selective[ metric ].median; + var floor = arms.floor[ metric ].median; + var delta = full > 0 ? ( ( 1 - sel / full ) * 100 ) : 0; + var cls = delta > 1 ? 'bb-perf-good' : ( delta < -1 ? 'bb-perf-bad' : '' ); + + return '' + + '' + label + '' + + '' + num( full, decimals ) + '' + + '' + num( sel, decimals ) + '' + + '' + num( floor, decimals ) + '' + + '' + ( delta >= 0 ? '−' : '+' ) + num( Math.abs( delta ), 1 ) + '%' + + ''; + } + + /** + * Render the benchmark result. + * + * @since BuddyBoss [BBVERSION] + * + * @param {Object} result Benchmark payload. + * + * @return {void} + */ + function renderBench( result ) { + var arms = result.arms; + var verdict = result.verdict; + var html = ''; + var i; + + html += '

Result — median of ' + esc( result.runs ) + ' runs per arm

'; + + html += '' + + '' + + ''; + + html += metricRow( 'Server time (ms)', arms, 'wall_ms', 1 ); + html += metricRow( 'Database time (ms)', arms, 'db_ms', 1 ); + html += metricRow( 'Queries', arms, 'queries', 0 ); + html += metricRow( 'Payload (KB)', arms, 'payload_kb', 1 ); + html += metricRow( 'Peak memory (KB)', arms, 'mem_kb', 0 ); + + html += '
MetricFullSelectiveFloor (_fields=id)Selective vs full
'; + + html += '
'; + html += '

Irreducible cost: ' + num( verdict.irreducible_pct ) + '% — the share of the full request that is spent before any field is built. No selection can go below that.

'; + html += '

Headroom: ' + num( verdict.headroom_ms ) + ' ms — the most any selection could save here. This one took ' + num( verdict.captured_of_headroom ) + '% of it (' + num( verdict.server_saving_ms ) + ' ms, ' + num( verdict.server_saving_pct ) + '% of the whole request).

'; + html += '

Payload: −' + num( verdict.payload_saving_pct ) + '%, queries saved: ' + verdict.queries_saved + ', database time saved: ' + num( verdict.db_ms_saved ) + ' ms.

'; + + if ( verdict.notes && verdict.notes.length ) { + html += '
    '; + + for ( i = 0; i < verdict.notes.length; i++ ) { + html += '
  • ' + esc( verdict.notes[ i ] ) + '
  • '; + } + + html += '
'; + } + + html += '
'; + + el( 'bb-perf-bench-result' ).innerHTML = html; + } + + if ( el( 'bb-perf-run' ) ) { + el( 'bb-perf-run' ).addEventListener( 'click', function () { + var button = el( 'bb-perf-run' ); + var status = el( 'bb-perf-bench-status' ); + + button.disabled = true; + status.textContent = 'Running…'; + el( 'bb-perf-bench-result' ).innerHTML = ''; + + post( 'bench', { + route: el( 'bb-perf-route' ).value, + query: el( 'bb-perf-query' ).value, + fields: el( 'bb-perf-fields' ).value, + embed: el( 'bb-perf-embed' ).value, + embed_fields: el( 'bb-perf-embed-fields' ).value, + runs: el( 'bb-perf-runs' ).value, + flush: el( 'bb-perf-flush' ).checked ? 1 : 0, + user_id: el( 'bb-perf-user' ).value + }, function ( error, data ) { + button.disabled = false; + status.textContent = error ? '' : 'Done.'; + + if ( error ) { + el( 'bb-perf-bench-result' ).innerHTML = '

' + esc( error ) + '

'; + return; + } + + renderBench( data ); + } ); + } ); + } + + /* ----------------------------------------------------------------- + * Seeder + * -------------------------------------------------------------- */ + + var seeding = false; + + /** + * Show seeding progress. + * + * @since BuddyBoss [BBVERSION] + * + * @param {Object} progress Progress payload. + * + * @return {void} + */ + function showProgress( progress ) { + var wrap = el( 'bb-seed-progress' ); + + wrap.hidden = false; + wrap.querySelector( '.bb-perf-bar span' ).style.width = progress.percent + '%'; + + el( 'bb-seed-status' ).textContent = progress.finished + ? 'Finished — ' + progress.created.users + ' members, ' + progress.created.groups + ' groups, ' + progress.created.activities + ' activities.' + : 'Phase: ' + progress.phase + ' — ' + progress.done + ' of ' + progress.total + ' (' + progress.percent + '%)'; + } + + /** + * Keep asking the server to do another chunk until the job is done. + * + * @since BuddyBoss [BBVERSION] + * + * @return {void} + */ + function pump() { + post( 'seed_tick', {}, function ( error, progress ) { + if ( error ) { + seeding = false; + el( 'bb-seed-status' ).textContent = error; + el( 'bb-seed-start' ).disabled = false; + return; + } + + showProgress( progress ); + + if ( progress.finished ) { + seeding = false; + el( 'bb-seed-start' ).disabled = false; + el( 'bb-seed-purge' ).disabled = false; + return; + } + + pump(); + } ); + } + + if ( el( 'bb-seed-start' ) ) { + el( 'bb-seed-start' ).addEventListener( 'click', function () { + if ( seeding ) { + return; + } + + seeding = true; + el( 'bb-seed-start' ).disabled = true; + + post( 'seed_start', { + users: el( 'bb-seed-users' ).value, + groups: el( 'bb-seed-groups' ).value, + activities: el( 'bb-seed-activities' ).value, + comments: el( 'bb-seed-comments' ).value, + reactions: el( 'bb-seed-reactions' ).value, + follows: el( 'bb-seed-follows' ).value, + friends: el( 'bb-seed-friends' ).value, + meta: el( 'bb-seed-meta' ).value + }, function ( error, progress ) { + if ( error ) { + seeding = false; + el( 'bb-seed-start' ).disabled = false; + el( 'bb-seed-status' ).textContent = error; + return; + } + + showProgress( progress ); + pump(); + } ); + } ); + } + + if ( el( 'bb-seed-resume' ) ) { + el( 'bb-seed-resume' ).addEventListener( 'click', function () { + if ( seeding ) { + return; + } + + seeding = true; + el( 'bb-seed-start' ).disabled = true; + pump(); + } ); + } + + if ( el( 'bb-seed-purge' ) ) { + el( 'bb-seed-purge' ).addEventListener( 'click', function () { + if ( ! window.confirm( 'Remove everything the last generate run created?' ) ) { + return; + } + + el( 'bb-seed-purge' ).disabled = true; + el( 'bb-seed-status' ).textContent = 'Removing…'; + el( 'bb-seed-progress' ).hidden = false; + + post( 'seed_purge', {}, function ( error, removed ) { + el( 'bb-seed-status' ).textContent = error + ? error + : 'Removed ' + removed.activities + ' activities, ' + removed.users + ' members, ' + removed.groups + ' groups.'; + } ); + } ); + } + + /* ----------------------------------------------------------------- + * Settings and log + * -------------------------------------------------------------- */ + + if ( el( 'bb-perf-save' ) ) { + el( 'bb-perf-save' ).addEventListener( 'click', function () { + post( 'save_settings', { + monitor_enabled: el( 'bb-set-enabled' ).checked ? 1 : 0, + monitor_deep: el( 'bb-set-deep' ).checked ? 1 : 0, + monitor_routes: el( 'bb-set-routes' ).value, + monitor_user_id: el( 'bb-set-user' ).value, + monitor_max_rows: el( 'bb-set-max' ).value + }, function ( error ) { + el( 'bb-perf-settings-status' ).textContent = error || 'Saved.'; + } ); + } ); + } + + if ( el( 'bb-perf-clear-log' ) ) { + el( 'bb-perf-clear-log' ).addEventListener( 'click', function () { + post( 'clear_log', {}, function () { + window.location.reload(); + } ); + } ); + } + + if ( el( 'bb-perf-uninstall' ) ) { + el( 'bb-perf-uninstall' ).addEventListener( 'click', function () { + if ( ! window.confirm( 'Drop the log table and every setting this tool created?' ) ) { + return; + } + + post( 'uninstall', {}, function () { + window.location.reload(); + } ); + } ); + } +}() ); diff --git a/src/bb-perf-lab/bb-perf-lab.php b/src/bb-perf-lab/bb-perf-lab.php new file mode 100644 index 00000000000..711b9dbe875 --- /dev/null +++ b/src/bb-perf-lab/bb-perf-lab.php @@ -0,0 +1,117 @@ + false, + // Routes the monitor records, as substrings of the route path. + 'monitor_routes' => 'buddyboss/v1', + // Whether to time every query individually. Costs a little, tells a lot. + 'monitor_deep' => false, + // Rows kept in the log. The oldest are dropped past this. + 'monitor_max_rows' => 5000, + // Requests from this user only, when set. 0 records everyone. + 'monitor_user_id' => 0, + ); + + $settings = wp_parse_args( (array) get_option( BB_PERF_LAB_SETTINGS, array() ), $defaults ); + + if ( '' === $key ) { + return $settings; + } + + return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback; +} + +/** + * Boot the Performance Lab. + * + * The monitor has to be in place before the REST server dispatches, and it wants + * `SAVEQUERIES` defined before anything queries the database, so this runs at + * `plugins_loaded` priority 1 rather than waiting for `bp_loaded`. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ +function bb_perf_lab_init() { + BB_Perf_Lab_Monitor::instance(); + + if ( is_admin() ) { + BB_Perf_Lab_Admin::instance(); + } +} +add_action( 'plugins_loaded', 'bb_perf_lab_init', 1 ); diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-admin.php b/src/bb-perf-lab/classes/class-bb-perf-lab-admin.php new file mode 100644 index 00000000000..b9f810658f1 --- /dev/null +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-admin.php @@ -0,0 +1,682 @@ +get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + BB_Perf_Lab_Monitor::install(); + } + + wp_enqueue_script( + 'bb-perf-lab', + plugins_url( 'assets/perf-lab.js', BB_PERF_LAB_DIR . '/bb-perf-lab.php' ), + array(), + BB_PERF_LAB_VERSION, + true + ); + + wp_enqueue_style( + 'bb-perf-lab', + plugins_url( 'assets/perf-lab.css', BB_PERF_LAB_DIR . '/bb-perf-lab.php' ), + array(), + BB_PERF_LAB_VERSION + ); + + wp_localize_script( + 'bb-perf-lab', + 'bbPerfLab', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'bb_perf_lab' ), + ) + ); + } + + /** + * Reject anyone who should not be here. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + protected function guard() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'buddyboss' ) ), 403 ); + } + + check_ajax_referer( 'bb_perf_lab', 'nonce' ); + } + + // ---- AJAX ---- + + /* + * Every handler below calls `guard()` first, which checks the capability + * and the nonce before a single `$_POST` key is read. The sniff cannot + * follow that across a method call, so it is switched off for this block + * and back on at the end of it. + */ + // phpcs:disable WordPress.Security.NonceVerification.Missing + + /** + * Begin a seeding job. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_seed_start() { + $this->guard(); + + $plan = array(); + + foreach ( array( 'users', 'groups', 'activities', 'comments', 'follows', 'friends', 'reactions', 'meta' ) as $key ) { + if ( isset( $_POST[ $key ] ) ) { + $plan[ $key ] = absint( wp_unslash( $_POST[ $key ] ) ); + } + } + + BB_Perf_Lab_Seeder::start( $plan ); + + wp_send_json_success( BB_Perf_Lab_Seeder::tick() ); + } + + /** + * Continue a seeding job. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_seed_tick() { + $this->guard(); + + $progress = BB_Perf_Lab_Seeder::tick(); + + if ( is_wp_error( $progress ) ) { + wp_send_json_error( array( 'message' => $progress->get_error_message() ) ); + } + + wp_send_json_success( $progress ); + } + + /** + * Undo the current seeding job. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_seed_purge() { + $this->guard(); + + wp_send_json_success( BB_Perf_Lab_Seeder::purge() ); + } + + /** + * Run the A/B comparison. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_bench() { + $this->guard(); + + $args = array( + 'route' => isset( $_POST['route'] ) ? sanitize_text_field( wp_unslash( $_POST['route'] ) ) : '/buddyboss/v1/activity', + 'query' => isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : 'per_page=20', + 'fields' => isset( $_POST['fields'] ) ? sanitize_text_field( wp_unslash( $_POST['fields'] ) ) : 'id,date,content', + 'embed' => isset( $_POST['embed'] ) ? sanitize_text_field( wp_unslash( $_POST['embed'] ) ) : '', + 'embed_fields' => isset( $_POST['embed_fields'] ) ? sanitize_text_field( wp_unslash( $_POST['embed_fields'] ) ) : '', + 'runs' => isset( $_POST['runs'] ) ? absint( wp_unslash( $_POST['runs'] ) ) : 7, + 'flush' => ! empty( $_POST['flush'] ), + 'user_id' => isset( $_POST['user_id'] ) ? absint( wp_unslash( $_POST['user_id'] ) ) : 0, + ); + + $result = BB_Perf_Lab_Bench::run( $args ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + wp_send_json_success( $result ); + } + + /** + * Empty the log. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_clear_log() { + $this->guard(); + + global $wpdb; + + $table = BB_Perf_Lab_Monitor::table(); + + $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange + + wp_send_json_success(); + } + + /** + * Save the monitor settings. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_save_settings() { + $this->guard(); + + $settings = array( + 'monitor_enabled' => ! empty( $_POST['monitor_enabled'] ), + 'monitor_deep' => ! empty( $_POST['monitor_deep'] ), + 'monitor_routes' => isset( $_POST['monitor_routes'] ) ? sanitize_text_field( wp_unslash( $_POST['monitor_routes'] ) ) : 'buddyboss/v1', + 'monitor_max_rows' => isset( $_POST['monitor_max_rows'] ) ? absint( wp_unslash( $_POST['monitor_max_rows'] ) ) : 5000, + 'monitor_user_id' => isset( $_POST['monitor_user_id'] ) ? absint( wp_unslash( $_POST['monitor_user_id'] ) ) : 0, + ); + + update_option( BB_PERF_LAB_SETTINGS, $settings, false ); + + wp_send_json_success( $settings ); + } + + /** + * Drop everything this tool created. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function ajax_uninstall() { + $this->guard(); + + BB_Perf_Lab_Monitor::uninstall(); + + delete_option( BB_PERF_LAB_SETTINGS ); + delete_option( BB_PERF_LAB_JOB ); + + wp_send_json_success(); + } + + // phpcs:enable WordPress.Security.NonceVerification.Missing + + // ---- Screen ---- + + /** + * Render the screen. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public function render() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $settings = bb_perf_lab_setting(); + $job = BB_Perf_Lab_Seeder::job(); + $counts = $this->counts(); + ?> +
+

+ +
+

+ + +

+
+ + + + render_bench(); + $this->render_seed( $job, $counts ); + $this->render_log(); + $this->render_settings( $settings ); + ?> +
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + +

+ +
+
+ + + get_results( "SELECT * FROM {$table} ORDER BY id DESC LIMIT 200" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + $summary = $wpdb->get_results( "SELECT route, selective, COUNT(*) AS hits, AVG(wall_ms) AS wall, AVG(db_ms) AS db, AVG(queries) AS queries, AVG(payload_kb) AS payload FROM {$table} GROUP BY route, selective ORDER BY route ASC, selective ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + ?> + + + + (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + 'activities' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}bp_activity" ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + 'activity_meta' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}bp_activity_meta" ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + 'groups' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}bp_groups" ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + ); + } +} diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php b/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php new file mode 100644 index 00000000000..a8ce1e22f98 --- /dev/null +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php @@ -0,0 +1,363 @@ + '/buddyboss/v1/activity', + 'query' => 'per_page=20', + 'fields' => 'id,date,content,user_id', + 'embed' => '', + 'embed_fields' => '', + 'runs' => 7, + 'flush' => true, + 'user_id' => 0, + ) + ); + + $runs = max( 1, min( 25, (int) $args['runs'] ) ); + + $switched = false; + $previous = get_current_user_id(); + + if ( ! empty( $args['user_id'] ) && (int) $args['user_id'] !== $previous ) { + wp_set_current_user( (int) $args['user_id'] ); + $switched = true; + } + + // The bench's own dispatches are not app traffic and must not be logged. + BB_Perf_Lab_Monitor::instance()->suspend( true ); + + $arms = array( + 'full' => array( + 'fields' => '', + 'embed_fields' => '', + ), + 'selective' => array( + 'fields' => $args['fields'], + 'embed_fields' => $args['embed_fields'], + ), + 'floor' => array( + 'fields' => 'id', + 'embed_fields' => 'id', + ), + ); + + $samples = array_fill_keys( array_keys( $arms ), array() ); + $error = null; + + /* + * Interleaved, not batched: a whole arm at a time would hand the second + * arm every cache the first one warmed, and the comparison would measure + * the ordering rather than the work. + */ + for ( $i = 0; $i < $runs; $i++ ) { + foreach ( $arms as $arm => $selection ) { + $sample = self::measure( $args, $selection, (bool) $args['flush'] ); + + if ( is_wp_error( $sample ) ) { + $error = $sample; + break 2; + } + + $samples[ $arm ][] = $sample; + } + } + + BB_Perf_Lab_Monitor::instance()->suspend( false ); + + if ( $switched ) { + wp_set_current_user( $previous ); + } + + if ( null !== $error ) { + return $error; + } + + $result = array( + 'route' => $args['route'], + 'query' => $args['query'], + 'fields' => $args['fields'], + 'embed' => $args['embed'], + 'runs' => $runs, + 'flush' => (bool) $args['flush'], + 'arms' => array(), + ); + + foreach ( $samples as $arm => $rows ) { + $result['arms'][ $arm ] = self::summarise( $rows ); + } + + $result['verdict'] = self::verdict( $result['arms'] ); + + return $result; + } + + /** + * Dispatch once and measure what it cost. + * + * The dispatch mirrors `WP_REST_Server::serve_request()`: `rest_do_request()` + * on its own returns a response nothing has filtered, so neither `_fields` + * nor the embeds would have been applied and the payload would be a fiction. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $args Run arguments. + * @param array $selection Field selection for this arm. + * @param bool $flush Whether to flush the object cache first. + * + * @return array|WP_Error Measurements, or the endpoint's error. + */ + protected static function measure( $args, $selection, $flush ) { + global $wpdb; + + if ( $flush ) { + wp_cache_flush(); + } + + $request = new WP_REST_Request( 'GET', $args['route'] ); + + $params = array(); + parse_str( (string) $args['query'], $params ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + if ( '' !== $selection['fields'] ) { + $request->set_param( '_fields', $selection['fields'] ); + } + + if ( '' !== (string) $selection['embed_fields'] ) { + $request->set_param( 'embed_fields', $selection['embed_fields'] ); + } + + $embed = '' !== (string) $args['embed'] ? array_filter( array_map( 'trim', explode( ',', (string) $args['embed'] ) ) ) : false; + + if ( ! empty( $embed ) ) { + $request->set_param( '_embed', implode( ',', $embed ) ); + } + + $server = rest_get_server(); + + $saved_from = ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) ? count( $wpdb->queries ) : 0; + $queries_at = (int) $wpdb->num_queries; + $mem_at = memory_get_peak_usage( true ); + $started = microtime( true ); + + $response = rest_do_request( $request ); + $response = apply_filters( 'rest_post_dispatch', rest_ensure_response( $response ), $server, $request ); + + if ( $response->is_error() ) { + $error = $response->as_error(); + + return new WP_Error( + 'bb_perf_lab_dispatch_failed', + sprintf( + /* translators: 1: route, 2: error message */ + __( 'The route %1$s answered with an error: %2$s', 'buddyboss' ), + $args['route'], + $error->get_error_message() + ) + ); + } + + $data = $server->response_to_data( $response, empty( $embed ) ? false : $embed ); + $payload = strlen( (string) wp_json_encode( $data ) ); + $wall = ( microtime( true ) - $started ) * 1000; + $mem = memory_get_peak_usage( true ) - $mem_at; + $count = (int) $wpdb->num_queries - $queries_at; + + $db_ms = 0.0; + + if ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) { + foreach ( array_slice( $wpdb->queries, $saved_from ) as $query ) { + $db_ms += isset( $query[1] ) ? (float) $query[1] * 1000 : 0.0; + } + } + + return array( + 'wall_ms' => $wall, + 'db_ms' => $db_ms, + 'queries' => $count, + 'payload_kb' => $payload / 1024, + 'mem_kb' => max( 0, $mem ) / 1024, + 'items' => ( is_array( $data ) && wp_is_numeric_array( $data ) ) ? count( $data ) : 1, + ); + } + + /** + * Reduce an arm's samples to the numbers worth reading. + * + * The median is what the comparison is built on. A mean would let one + * unlucky run -- a checkpoint, a neighbour on the box -- move the answer, + * which over a handful of runs it very often does. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $rows Samples for one arm. + * + * @return array + */ + protected static function summarise( $rows ) { + $out = array( 'samples' => count( $rows ) ); + + foreach ( array( 'wall_ms', 'db_ms', 'queries', 'payload_kb', 'mem_kb', 'items' ) as $metric ) { + $values = wp_list_pluck( $rows, $metric ); + + sort( $values ); + + $out[ $metric ] = array( + 'median' => round( self::median( $values ), 2 ), + 'min' => round( (float) reset( $values ), 2 ), + 'max' => round( (float) end( $values ), 2 ), + ); + } + + return $out; + } + + /** + * Median of a sorted list. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $values Sorted values. + * + * @return float + */ + protected static function median( $values ) { + $count = count( $values ); + + if ( 0 === $count ) { + return 0.0; + } + + $middle = (int) floor( ( $count - 1 ) / 2 ); + + if ( 0 !== $count % 2 ) { + return (float) $values[ $middle ]; + } + + return ( (float) $values[ $middle ] + (float) $values[ $middle + 1 ] ) / 2; + } + + /** + * Turn the three arms into a reading of what the numbers mean. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $arms Summarised arms. + * + * @return array + */ + protected static function verdict( $arms ) { + $full = $arms['full']['wall_ms']['median']; + $selective = $arms['selective']['wall_ms']['median']; + $floor = $arms['floor']['wall_ms']['median']; + + // What the request costs before any field is built at all. + $irreducible = $full > 0 ? ( $floor / $full ) * 100 : 0; + + // The most any selection could ever save. + $headroom = max( 0, $full - $floor ); + + // What this selection did save. + $saved = max( 0, $full - $selective ); + + $verdict = array( + 'server_saving_ms' => round( $saved, 2 ), + 'server_saving_pct' => $full > 0 ? round( ( $saved / $full ) * 100, 1 ) : 0, + 'payload_saving_pct' => $arms['full']['payload_kb']['median'] > 0 + ? round( ( 1 - ( $arms['selective']['payload_kb']['median'] / $arms['full']['payload_kb']['median'] ) ) * 100, 1 ) + : 0, + 'irreducible_pct' => round( $irreducible, 1 ), + 'headroom_ms' => round( $headroom, 2 ), + 'captured_of_headroom' => $headroom > 0 ? round( ( $saved / $headroom ) * 100, 1 ) : 0, + 'queries_saved' => $arms['full']['queries']['median'] - $arms['selective']['queries']['median'], + 'db_ms_saved' => round( $arms['full']['db_ms']['median'] - $arms['selective']['db_ms']['median'], 2 ), + ); + + $notes = array(); + + if ( $verdict['irreducible_pct'] > 80 ) { + $notes[] = __( 'Over 80% of this request is spent before any field is built -- the query, the permission checks, the cache priming. Selective fields cannot reach that, so the end-to-end gain will stay small no matter how narrow the selection. Look at the query itself.', 'buddyboss' ); + } + + if ( $verdict['captured_of_headroom'] > 70 ) { + $notes[] = __( 'The selection is taking most of the saving that was available to it. Field building is working as intended.', 'buddyboss' ); + } elseif ( $headroom > 0 && $verdict['captured_of_headroom'] < 30 ) { + $notes[] = __( 'There was room to save and the selection took little of it, which points at work still running for fields nobody asked for.', 'buddyboss' ); + } + + if ( $verdict['payload_saving_pct'] > 30 && $verdict['server_saving_pct'] < 10 ) { + $notes[] = __( 'The payload shrank far more than the time did. On a fast connection that trade is nearly invisible; on a slow or metered one it is the whole point.', 'buddyboss' ); + } + + $verdict['notes'] = $notes; + + return $verdict; + } +} diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php b/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php new file mode 100644 index 00000000000..ff0fb201484 --- /dev/null +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php @@ -0,0 +1,414 @@ +prefix . 'bb_perf_lab_log'; + } + + /** + * Create the log table. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public static function install() { + global $wpdb; + + $table = self::table(); + $collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id bigint(20) NOT NULL AUTO_INCREMENT, + logged_at datetime NOT NULL, + route varchar(191) NOT NULL DEFAULT '', + method varchar(10) NOT NULL DEFAULT '', + label varchar(60) NOT NULL DEFAULT '', + fields text NOT NULL, + embed varchar(191) NOT NULL DEFAULT '', + embed_fields varchar(191) NOT NULL DEFAULT '', + selective tinyint(1) NOT NULL DEFAULT 0, + per_page smallint(6) NOT NULL DEFAULT 0, + items smallint(6) NOT NULL DEFAULT 0, + wall_ms float NOT NULL DEFAULT 0, + db_ms float NOT NULL DEFAULT 0, + queries int(11) NOT NULL DEFAULT 0, + mem_peak_kb int(11) NOT NULL DEFAULT 0, + payload_kb float NOT NULL DEFAULT 0, + user_id bigint(20) NOT NULL DEFAULT 0, + slow_queries longtext NOT NULL, + PRIMARY KEY (id), + KEY logged_at (logged_at), + KEY route (route), + KEY selective (selective), + KEY label (label) + ) {$collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * Drop the log table. + * + * @since BuddyBoss [BBVERSION] + * + * @return void + */ + public static function uninstall() { + global $wpdb; + + $table = self::table(); + + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching + } + + /** + * Stop recording, so the bench's own dispatches do not land in the log. + * + * @since BuddyBoss [BBVERSION] + * + * @param bool $suspended Optional. Whether to suspend. Default true. + * + * @return void + */ + public function suspend( $suspended = true ) { + $this->suspended = (bool) $suspended; + } + + /** + * Take the opening measurements. + * + * Only the outermost request is tracked. WordPress dispatches embedded items + * through the same filter, and their cost belongs to the request that + * embedded them, not to a row of their own. + * + * @since BuddyBoss [BBVERSION] + * + * @param mixed $result Response to replace the requested version with. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return mixed The result, untouched. + */ + public function begin( $result, $server, $request ) { + if ( $this->suspended || null !== $this->tracking || ! $this->wanted( $request ) ) { + return $result; + } + + global $wpdb; + + $this->tracking = $request; + $this->start = array( + 'time' => microtime( true ), + 'queries' => (int) $wpdb->num_queries, + 'saved' => ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) ? count( $wpdb->queries ) : 0, + 'mem' => memory_get_peak_usage( true ), + ); + + return $result; + } + + /** + * Take the closing measurements and write the row. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $served Response data about to be echoed. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return array The response data, untouched. + */ + public function finish( $served, $server, $request ) { + if ( $this->suspended || $this->tracking !== $request ) { + return $served; + } + + global $wpdb; + + $wall = ( microtime( true ) - $this->start['time'] ) * 1000; + $count = (int) $wpdb->num_queries - $this->start['queries']; + $mem = memory_get_peak_usage( true ) - $this->start['mem']; + + list( $db_ms, $slow ) = $this->query_timings( $this->start['saved'] ); + + $payload = is_scalar( $served ) ? strlen( (string) $served ) : strlen( (string) wp_json_encode( $served ) ); + $items = ( is_array( $served ) && wp_is_numeric_array( $served ) ) ? count( $served ) : 1; + + $fields = (string) $request->get_param( '_fields' ); + $embed = $request->get_param( '_embed' ); + $embed_fields = $request->get_param( 'embed_fields' ); + + $this->record( + array( + 'route' => $request->get_route(), + 'method' => $request->get_method(), + 'label' => (string) $request->get_param( 'bb_perf_label' ), + 'fields' => $fields, + 'embed' => is_array( $embed ) ? implode( ',', $embed ) : (string) $embed, + 'embed_fields' => is_array( $embed_fields ) ? wp_json_encode( $embed_fields ) : (string) $embed_fields, + 'selective' => ( '' !== $fields || ! empty( $embed_fields ) ) ? 1 : 0, + 'per_page' => (int) $request->get_param( 'per_page' ), + 'items' => $items, + 'wall_ms' => round( $wall, 2 ), + 'db_ms' => round( $db_ms, 2 ), + 'queries' => $count, + 'mem_peak_kb' => (int) round( max( 0, $mem ) / 1024 ), + 'payload_kb' => round( $payload / 1024, 2 ), + 'user_id' => get_current_user_id(), + 'slow_queries' => $slow, + ) + ); + + $this->tracking = null; + + return $served; + } + + /** + * Write one row, then trim the log back to its ceiling. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $row Row to insert. + * + * @return void + */ + public function record( $row ) { + global $wpdb; + + $table = self::table(); + + $row['logged_at'] = current_time( 'mysql' ); + $row['route'] = substr( (string) $row['route'], 0, 191 ); + $row['label'] = substr( (string) $row['label'], 0, 60 ); + + $wpdb->insert( $table, $row ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + $max = (int) bb_perf_lab_setting( 'monitor_max_rows' ); + + if ( $max < 1 ) { + return; + } + + /* + * Trimming on every write would double the cost of logging, so it only + * happens now and then. The log drifts a little over its ceiling between + * trims, which is of no consequence. + */ + if ( 0 !== $wpdb->insert_id % 50 ) { + return; + } + + $keep = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} ORDER BY id DESC LIMIT 1 OFFSET %d", $max ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + if ( $keep > 0 ) { + $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id <= %d", $keep ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + } + } + + /** + * Total the database time, and keep the queries worth looking at. + * + * Without `SAVEQUERIES` there are no timings to total, and the row carries a + * query count only. + * + * @since BuddyBoss [BBVERSION] + * + * @param int $from Index into `$wpdb->queries` the request began at. + * + * @return array { + * @type float $0 Milliseconds spent in the database. + * @type string $1 JSON list of the slowest queries, or ''. + * } + */ + protected function query_timings( $from ) { + global $wpdb; + + if ( ! isset( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + return array( 0.0, '' ); + } + + $queries = array_slice( $wpdb->queries, $from ); + + if ( empty( $queries ) ) { + return array( 0.0, '' ); + } + + $total = 0.0; + $timings = array(); + + foreach ( $queries as $query ) { + $elapsed = isset( $query[1] ) ? (float) $query[1] : 0.0; + $total += $elapsed; + + $timings[] = array( + 'ms' => round( $elapsed * 1000, 2 ), + 'sql' => substr( preg_replace( '/\s+/', ' ', (string) $query[0] ), 0, 400 ), + 'caller' => substr( isset( $query[2] ) ? (string) $query[2] : '', 0, 300 ), + ); + } + + usort( + $timings, + function ( $a, $b ) { + if ( $a['ms'] === $b['ms'] ) { + return 0; + } + + return ( $a['ms'] < $b['ms'] ) ? 1 : -1; + } + ); + + return array( $total * 1000, (string) wp_json_encode( array_slice( $timings, 0, 12 ) ) ); + } + + /** + * Whether this request is one the monitor was asked to record. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request $request Request to consider. + * + * @return bool + */ + protected function wanted( $request ) { + if ( ! $request instanceof WP_REST_Request ) { + return false; + } + + $only_user = (int) bb_perf_lab_setting( 'monitor_user_id' ); + + if ( $only_user > 0 && get_current_user_id() !== $only_user ) { + return false; + } + + $routes = array_filter( array_map( 'trim', explode( ',', (string) bb_perf_lab_setting( 'monitor_routes' ) ) ) ); + + if ( empty( $routes ) ) { + return true; + } + + $route = $request->get_route(); + + foreach ( $routes as $needle ) { + if ( false !== strpos( $route, $needle ) ) { + return true; + } + } + + return false; + } +} diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-seeder.php b/src/bb-perf-lab/classes/class-bb-perf-lab-seeder.php new file mode 100644 index 00000000000..a55942a4a65 --- /dev/null +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-seeder.php @@ -0,0 +1,1309 @@ + 40, + 'groups' => 15, + 'members' => 200, + 'graph' => 120, + 'activities' => 1500, + 'comments' => 800, + 'threads' => 150, + 'reactions' => 400, + ); + + /** + * Phases, in the order they run. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + const PHASES = array( 'users', 'groups', 'members', 'graph', 'activities', 'comments', 'threads', 'reactions' ); + + /** + * Start a new seeding job. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $plan { + * Optional. What to create. + * + * @type int $users Members to add. Default 400. + * @type int $groups Groups to add. Default 30. + * @type int $activities Activities to add. Default 50000. + * @type int $comments Comments to add. Default 8000. + * @type int $follows Follows per member. Default 25. + * @type int $friends Friendships per member. Default 12. + * @type int $reactions Favourites to hand out. Default 15000. + * @type int $meta Metadata rows per activity. Default 4. + * } + * + * @return array The job. + */ + public static function start( $plan = array() ) { + $plan = wp_parse_args( + $plan, + array( + 'users' => 400, + 'groups' => 30, + 'activities' => 50000, + 'comments' => 8000, + 'follows' => 25, + 'friends' => 12, + 'reactions' => 15000, + 'meta' => 4, + ) + ); + + foreach ( $plan as $key => $value ) { + $plan[ $key ] = max( 0, (int) $value ); + } + + $job = array( + 'id' => (string) wp_generate_password( 8, false ), + 'plan' => $plan, + 'phase' => 0, + 'done' => array_fill_keys( self::PHASES, 0 ), + 'created' => array( + 'user_ids' => array(), + 'group_ids' => array(), + 'activity_from' => 0, + 'activity_to' => 0, + ), + 'started' => time(), + 'finished' => 0, + 'log' => array(), + ); + + update_option( BB_PERF_LAB_JOB, $job, false ); + + return $job; + } + + /** + * Read the current job. + * + * @since BuddyBoss [BBVERSION] + * + * @return array|null + */ + public static function job() { + $job = get_option( BB_PERF_LAB_JOB, null ); + + return is_array( $job ) ? $job : null; + } + + /** + * Work on the job for one chunk, then hand control back. + * + * @since BuddyBoss [BBVERSION] + * + * @return array|WP_Error Progress, or an error when there is no job. + */ + public static function tick() { + $job = self::job(); + + if ( null === $job ) { + return new WP_Error( 'bb_perf_lab_no_job', __( 'There is no seeding job to continue.', 'buddyboss' ) ); + } + + if ( ! empty( $job['finished'] ) ) { + return self::progress( $job ); + } + + /* + * Seeding writes tens of thousands of rows. Leaving the usual invalidation + * and counting hooks in place would have the install recount its way + * through every one of them, turning minutes into hours, and none of it + * survives the cache flush the benchmark does anyway. + */ + wp_defer_term_counting( true ); + wp_defer_comment_counting( true ); + remove_action( 'bp_activity_after_save', 'bp_activity_at_name_send_emails' ); + + $started = microtime( true ); + + // Keep going while there is time in hand, so one round trip does real work. + while ( microtime( true ) - $started < 12 ) { + if ( ! isset( self::PHASES[ $job['phase'] ] ) ) { + $job['finished'] = time(); + break; + } + + $phase = self::PHASES[ $job['phase'] ]; + $target = self::target( $job, $phase ); + + if ( $job['done'][ $phase ] >= $target ) { + ++$job['phase']; + continue; + } + + $method = 'seed_' . $phase; + $size = min( self::CHUNK[ $phase ], $target - $job['done'][ $phase ] ); + + $job = self::$method( $job, $size ); + + $job['done'][ $phase ] += $size; + } + + wp_defer_term_counting( false ); + wp_defer_comment_counting( false ); + + update_option( BB_PERF_LAB_JOB, $job, false ); + + return self::progress( $job ); + } + + /** + * How many rows a phase has to produce. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param string $phase Phase name. + * + * @return int + */ + protected static function target( $job, $phase ) { + $plan = $job['plan']; + + switch ( $phase ) { + case 'users': + return $plan['users']; + case 'groups': + return $plan['groups']; + case 'members': + // Every group gets a slice of the membership. + return $plan['groups'] * 12; + case 'graph': + return $plan['users']; + case 'activities': + return $plan['activities']; + case 'comments': + return $plan['comments']; + case 'threads': + // Comment trees are rebuilt for a share of the parents. + return (int) ceil( $plan['comments'] / 6 ); + case 'reactions': + return $plan['reactions']; + } + + return 0; + } + + /** + * Summarise the job for the browser. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * + * @return array + */ + protected static function progress( $job ) { + $total = 0; + $done = 0; + + foreach ( self::PHASES as $phase ) { + $target = self::target( $job, $phase ); + $total += $target; + $done += min( $job['done'][ $phase ], $target ); + } + + return array( + 'id' => $job['id'], + 'phase' => isset( self::PHASES[ $job['phase'] ] ) ? self::PHASES[ $job['phase'] ] : 'done', + 'done' => $done, + 'total' => $total, + 'percent' => $total > 0 ? round( ( $done / $total ) * 100, 1 ) : 100, + 'finished' => ! empty( $job['finished'] ), + 'detail' => $job['done'], + 'created' => array( + 'users' => count( $job['created']['user_ids'] ), + 'groups' => count( $job['created']['group_ids'] ), + 'activities' => $job['created']['activity_to'] > 0 + ? ( $job['created']['activity_to'] - $job['created']['activity_from'] + 1 ) + : 0, + ), + ); + } + + // ---- Phases ---- + + /** + * Add members, with the profile data a real member carries. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Members to add. + * + * @return array The job. + */ + protected static function seed_users( $job, $size ) { + $types = bp_get_member_types(); + $types = ! empty( $types ) ? array_keys( $types ) : array(); + + for ( $i = 0; $i < $size; $i++ ) { + $n = $job['done']['users'] + $i; + $login = sprintf( 'perf_%s_%d', $job['id'], $n ); + $first = self::pick( self::$first_names ); + $last = self::pick( self::$last_names ); + + $user_id = wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => wp_generate_password( 16 ), + 'user_email' => $login . '@perf.example', + 'display_name' => $first . ' ' . $last, + 'first_name' => $first, + 'last_name' => $last, + 'role' => 'subscriber', + 'user_registered' => self::past_datetime( wp_rand( 30, 900 ) ), + ) + ); + + if ( is_wp_error( $user_id ) ) { + continue; + } + + $job['created']['user_ids'][] = $user_id; + + update_user_meta( $user_id, 'bb_perf_lab_seed', $job['id'] ); + + // Profile data, so the xprofile joins have something to find. + xprofile_set_field_data( 1, $user_id, $first ); + xprofile_set_field_data( 2, $user_id, $last ); + xprofile_set_field_data( 3, $user_id, $first . ' ' . $last ); + + if ( ! empty( $types ) ) { + bp_set_member_type( $user_id, self::pick( $types ) ); + } + + // Recent activity, which is what member directories sort on. + bp_update_user_last_activity( $user_id, self::past_datetime( wp_rand( 0, 45 ) ) ); + } + + return $job; + } + + /** + * Add groups. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Groups to add. + * + * @return array The job. + */ + protected static function seed_groups( $job, $size ) { + $users = self::user_pool( $job ); + $statuses = array( 'public', 'public', 'public', 'private', 'hidden' ); + + if ( empty( $users ) ) { + return $job; + } + + for ( $i = 0; $i < $size; $i++ ) { + $n = $job['done']['groups'] + $i; + $name = self::pick( self::$group_words ) . ' ' . self::pick( self::$group_nouns ); + + $group_id = groups_create_group( + array( + 'creator_id' => self::pick( $users ), + 'name' => $name . ' #' . $n, + 'slug' => sanitize_title( $name . '-' . $job['id'] . '-' . $n ), + 'description' => self::paragraph( 2 ), + 'status' => self::pick( $statuses ), + 'enable_forum' => 0, + 'date_created' => self::past_datetime( wp_rand( 60, 800 ) ), + ) + ); + + if ( empty( $group_id ) || is_wp_error( $group_id ) ) { + continue; + } + + $job['created']['group_ids'][] = $group_id; + + groups_update_groupmeta( $group_id, 'bb_perf_lab_seed', $job['id'] ); + groups_update_groupmeta( $group_id, 'last_activity', self::past_datetime( wp_rand( 0, 40 ) ) ); + } + + return $job; + } + + /** + * Put members into groups. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Memberships to add. + * + * @return array The job. + */ + protected static function seed_members( $job, $size ) { + global $wpdb; + + $users = self::user_pool( $job ); + $groups = $job['created']['group_ids']; + + if ( empty( $users ) || empty( $groups ) ) { + return $job; + } + + $rows = array(); + + for ( $i = 0; $i < $size; $i++ ) { + $group_id = self::pick( $groups ); + $user_id = self::pick( $users ); + $is_admin = 0 === wp_rand( 0, 11 ) ? 1 : 0; + $is_mod = ( 0 === $is_admin && 0 === wp_rand( 0, 9 ) ) ? 1 : 0; + + $rows[] = $wpdb->prepare( + '(%d, %d, %d, %d, %s, %d, %d, %d)', + $group_id, + $user_id, + $is_admin, + $is_mod, + self::past_datetime( wp_rand( 1, 500 ) ), + 1, + 0, + 0 + ); + } + + if ( empty( $rows ) ) { + return $job; + } + + $table = $wpdb->prefix . 'bp_groups_members'; + + $sql = "INSERT INTO {$table} (group_id, user_id, is_admin, is_mod, date_modified, is_confirmed, is_banned, invite_sent) VALUES " . implode( ',', $rows ); + + $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + return $job; + } + + /** + * Build the social graph: follows and friendships. + * + * These are what make the per-member fields expensive, and so what makes + * declining to build them worth something. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Members to wire up. + * + * @return array The job. + */ + protected static function seed_graph( $job, $size ) { + global $wpdb; + + $users = self::user_pool( $job ); + $count = count( $users ); + + if ( $count < 3 ) { + return $job; + } + + $follow_rows = array(); + $friend_rows = array(); + + $follows = min( $job['plan']['follows'], $count - 1 ); + $friends = min( $job['plan']['friends'], $count - 1 ); + + for ( $i = 0; $i < $size; $i++ ) { + $index = $job['done']['graph'] + $i; + + if ( ! isset( $users[ $index ] ) ) { + break; + } + + $follower = $users[ $index ]; + + for ( $f = 0; $f < $follows; $f++ ) { + $leader = self::pick( $users ); + + if ( $leader === $follower ) { + continue; + } + + $follow_rows[] = $wpdb->prepare( '(%d, %d)', $leader, $follower ); + } + + for ( $f = 0; $f < $friends; $f++ ) { + $friend = self::pick( $users ); + + if ( $friend === $follower ) { + continue; + } + + $friend_rows[] = $wpdb->prepare( + '(%d, %d, %d, %d, %s)', + $follower, + $friend, + 1, + 0, + self::past_datetime( wp_rand( 1, 600 ) ) + ); + } + } + + if ( ! empty( $follow_rows ) && self::table_exists( $wpdb->prefix . 'bp_follow' ) ) { + $table = $wpdb->prefix . 'bp_follow'; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "INSERT IGNORE INTO {$table} (leader_id, follower_id) VALUES " . implode( ',', $follow_rows ) ); + } + + if ( ! empty( $friend_rows ) && self::table_exists( $wpdb->prefix . 'bp_friends' ) ) { + $table = $wpdb->prefix . 'bp_friends'; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "INSERT INTO {$table} (initiator_user_id, friend_user_id, is_confirmed, is_limited, date_created) VALUES " . implode( ',', $friend_rows ) ); + } + + return $job; + } + + /** + * Add activities, with the spread of types a real feed carries. + * + * Written straight to the table. `bp_activity_add()` would fire the whole + * notification, mention and moderation chain per row, which at this volume + * takes hours and produces the same rows. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Activities to add. + * + * @return array The job. + */ + protected static function seed_activities( $job, $size ) { + global $wpdb; + + $users = self::user_pool( $job ); + $groups = $job['created']['group_ids']; + + if ( empty( $users ) ) { + return $job; + } + + $table = $wpdb->prefix . 'bp_activity'; + $rows = array(); + + for ( $i = 0; $i < $size; $i++ ) { + $user_id = self::pick( $users ); + $shape = self::activity_shape( $groups ); + $content = self::activity_content( $shape['type'], $users ); + + $rows[] = $wpdb->prepare( + '(%d, %s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %d, %d, %d, %d, %s, %s)', + $user_id, + $shape['component'], + $shape['type'], + self::action_text( $user_id, $shape['type'] ), + '', + $content, + '', + $shape['item_id'], + 0, + self::past_datetime( wp_rand( 0, 700 ), true ), + self::past_datetime( wp_rand( 0, 700 ), true ), + 0, + 0, + 0, + 0, + $shape['privacy'], + 'published' + ); + } + + if ( empty( $rows ) ) { + return $job; + } + + $sql = "INSERT INTO {$table} (user_id, component, type, action, post_title, content, primary_link, item_id, secondary_item_id, date_recorded, date_updated, hide_sitewide, mptt_left, mptt_right, is_spam, privacy, status) VALUES " . implode( ',', $rows ); + + $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + $first = (int) $wpdb->insert_id; + $last = $first + count( $rows ) - 1; + + if ( 0 === $job['created']['activity_from'] ) { + $job['created']['activity_from'] = $first; + } + + $job['created']['activity_to'] = $last; + + self::seed_activity_meta( $job, $first, $last ); + + return $job; + } + + /** + * Give a run of activities their metadata. + * + * Metadata is where a lot of an activity's cost hides: the endpoint reads it + * for the embed URL, the attachments, the edit history and the closed-comment + * state, and a feed of bare rows never exercises any of that. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $first First activity ID. + * @param int $last Last activity ID. + * + * @return void + */ + protected static function seed_activity_meta( $job, $first, $last ) { + global $wpdb; + + $per = max( 1, (int) $job['plan']['meta'] ); + $table = $wpdb->prefix . 'bp_activity_meta'; + $rows = array(); + + for ( $id = $first; $id <= $last; $id++ ) { + $meta = array( + 'bb_perf_lab_seed' => $job['id'], + 'bp_activity_reactions_count' => (string) wp_rand( 0, 40 ), + ); + + if ( wp_rand( 0, 4 ) > 2 ) { + $meta['_link_embed'] = 'https://example.com/story/' . wp_rand( 1000, 99999 ); + } + + if ( wp_rand( 0, 6 ) > 4 ) { + $meta['_is_edited'] = self::past_datetime( wp_rand( 0, 60 ) ); + } + + if ( wp_rand( 0, 9 ) > 7 ) { + $meta['bb_is_closed_comments'] = '1'; + } + + if ( wp_rand( 0, 3 ) > 2 ) { + $meta['bp_activity_mentioned_users'] = wp_json_encode( array( wp_rand( 2, 50 ) ) ); + } + + // Top up to the requested density with inert keys, so the metadata + // read costs what a mature install's would. + $extra = $per - count( $meta ); + + for ( $e = 0; $e < $extra; $e++ ) { + $meta[ 'bb_perf_filler_' . $e ] = wp_generate_password( 24, false ); + } + + foreach ( $meta as $key => $value ) { + $rows[] = $wpdb->prepare( '(%d, %s, %s)', $id, $key, $value ); + } + + // Flush periodically so a wide range cannot build a giant statement. + if ( count( $rows ) >= 2000 ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "INSERT INTO {$table} (activity_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) ); + $rows = array(); + } + } + + if ( ! empty( $rows ) ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "INSERT INTO {$table} (activity_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) ); + } + } + + /** + * Hang comments off the activities already seeded. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Comments to add. + * + * @return array The job. + */ + protected static function seed_comments( $job, $size ) { + global $wpdb; + + $users = self::user_pool( $job ); + $parents = self::parent_pool( $job ); + + if ( empty( $users ) || empty( $parents ) ) { + return $job; + } + + $table = $wpdb->prefix . 'bp_activity'; + $rows = array(); + + for ( $i = 0; $i < $size; $i++ ) { + $parent = self::pick( $parents ); + $user_id = self::pick( $users ); + + $rows[] = $wpdb->prepare( + '(%d, %s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %d, %d, %d, %d, %s, %s)', + $user_id, + 'activity', + 'activity_comment', + self::action_text( $user_id, 'activity_comment' ), + '', + self::paragraph( 1 ), + '', + $parent, + $parent, + self::past_datetime( wp_rand( 0, 300 ), true ), + self::past_datetime( wp_rand( 0, 300 ), true ), + 0, + 0, + 0, + 0, + 'public', + 'published' + ); + } + + if ( empty( $rows ) ) { + return $job; + } + + $sql = "INSERT INTO {$table} (user_id, component, type, action, post_title, content, primary_link, item_id, secondary_item_id, date_recorded, date_updated, hide_sitewide, mptt_left, mptt_right, is_spam, privacy, status) VALUES " . implode( ',', $rows ); + + $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + $first = (int) $wpdb->insert_id; + $job['created']['activity_to'] = max( (int) $job['created']['activity_to'], $first + count( $rows ) - 1 ); + + return $job; + } + + /** + * Rebuild the comment trees, so threaded reads behave like the real thing. + * + * Comments went in as plain rows; without their nested-set bounds a threaded + * request would find nothing under its parents. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Parents to rebuild. + * + * @return array The job. + */ + protected static function seed_threads( $job, $size ) { + global $wpdb; + + $from = (int) $job['created']['activity_from']; + $to = (int) $job['created']['activity_to']; + + if ( $from < 1 || $to < $from ) { + return $job; + } + + $offset = (int) $job['done']['threads']; + + // A range scan over rows this job just wrote; there is no cache to consult. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $parents = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT item_id FROM {$wpdb->prefix}bp_activity WHERE type = 'activity_comment' AND id BETWEEN %d AND %d ORDER BY item_id ASC LIMIT %d OFFSET %d", + $from, + $to, + $size, + $offset + ) + ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + foreach ( (array) $parents as $parent_id ) { + BP_Activity_Activity::rebuild_activity_comment_tree( (int) $parent_id ); + } + + return $job; + } + + /** + * Hand out favourites. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * @param int $size Favourites to add. + * + * @return array The job. + */ + protected static function seed_reactions( $job, $size ) { + global $wpdb; + + $users = self::user_pool( $job ); + $parents = self::parent_pool( $job ); + + if ( empty( $users ) || empty( $parents ) ) { + return $job; + } + + $favourites = array(); + $counts = array(); + + for ( $i = 0; $i < $size; $i++ ) { + $user_id = self::pick( $users ); + $activity_id = self::pick( $parents ); + + $favourites[ $user_id ][] = $activity_id; + + if ( ! isset( $counts[ $activity_id ] ) ) { + $counts[ $activity_id ] = 0; + } + + ++$counts[ $activity_id ]; + } + + foreach ( $favourites as $user_id => $ids ) { + $existing = (array) bp_get_user_meta( $user_id, 'bp_favorite_activities', true ); + $merged = array_values( array_unique( array_merge( array_filter( $existing ), $ids ) ) ); + + bp_update_user_meta( $user_id, 'bp_favorite_activities', $merged ); + } + + $table = $wpdb->prefix . 'bp_activity_meta'; + $rows = array(); + + foreach ( $counts as $activity_id => $count ) { + $rows[] = $wpdb->prepare( '(%d, %s, %s)', $activity_id, 'favorite_count', (string) $count ); + } + + if ( ! empty( $rows ) ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "INSERT INTO {$table} (activity_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) ); + } + + return $job; + } + + // ---- Teardown ---- + + /** + * Remove everything the current job created. + * + * Only rows inside the ID ranges this run recorded are touched, so an + * install that had content before seeding keeps it. + * + * @since BuddyBoss [BBVERSION] + * + * @return array Counts of what was removed. + */ + public static function purge() { + global $wpdb; + + $job = self::job(); + + if ( null === $job ) { + return array( + 'activities' => 0, + 'users' => 0, + 'groups' => 0, + ); + } + + $removed = array( + 'activities' => 0, + 'users' => 0, + 'groups' => 0, + ); + + $from = (int) $job['created']['activity_from']; + $to = (int) $job['created']['activity_to']; + + if ( $from > 0 && $to >= $from ) { + $removed['activities'] = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}bp_activity WHERE id BETWEEN %d AND %d", $from, $to ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}bp_activity_meta WHERE activity_id BETWEEN %d AND %d", $from, $to ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + } + + foreach ( (array) $job['created']['group_ids'] as $group_id ) { + groups_delete_group( (int) $group_id ); + ++$removed['groups']; + } + + require_once ABSPATH . 'wp-admin/includes/user.php'; + + foreach ( (array) $job['created']['user_ids'] as $user_id ) { + wp_delete_user( (int) $user_id ); + ++$removed['users']; + } + + delete_option( BB_PERF_LAB_JOB ); + + return $removed; + } + + // ---- Content ---- + + /** + * Decide what kind of activity to write next. + * + * The mix follows what a working community actually produces: mostly status + * updates, a good share of them inside groups, and a tail of attachments and + * forum posts. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $groups Group IDs available. + * + * @return array { + * @type string $component Component the activity belongs to. + * @type string $type Activity type. + * @type int $item_id Primary item, e.g. the group. + * @type string $privacy Privacy setting. + * } + */ + protected static function activity_shape( $groups ) { + $roll = wp_rand( 1, 100 ); + + if ( $roll <= 44 ) { + return array( + 'component' => 'activity', + 'type' => 'activity_update', + 'item_id' => 0, + 'privacy' => self::pick( array( 'public', 'public', 'public', 'loggedin', 'friends', 'onlyme' ) ), + ); + } + + if ( $roll <= 74 && ! empty( $groups ) ) { + return array( + 'component' => 'groups', + 'type' => 'activity_update', + 'item_id' => self::pick( $groups ), + 'privacy' => 'public', + ); + } + + if ( $roll <= 82 ) { + return array( + 'component' => 'activity', + 'type' => 'activity_update', + 'item_id' => 0, + 'privacy' => 'media', + ); + } + + if ( $roll <= 88 ) { + return array( + 'component' => 'activity', + 'type' => 'activity_update', + 'item_id' => 0, + 'privacy' => 'document', + ); + } + + if ( $roll <= 92 ) { + return array( + 'component' => 'activity', + 'type' => 'activity_update', + 'item_id' => 0, + 'privacy' => 'video', + ); + } + + if ( $roll <= 96 ) { + return array( + 'component' => 'forums', + 'type' => 'bbp_topic_create', + 'item_id' => wp_rand( 1, 400 ), + 'privacy' => 'public', + ); + } + + return array( + 'component' => 'members', + 'type' => self::pick( array( 'new_member', 'updated_profile', 'new_avatar', 'friendship_created' ) ), + 'item_id' => 0, + 'privacy' => 'public', + ); + } + + /** + * Body text for an activity. + * + * @since BuddyBoss [BBVERSION] + * + * @param string $type Activity type. + * @param array $users Member IDs, for mentions. + * + * @return string + */ + protected static function activity_content( $type, $users ) { + if ( in_array( $type, array( 'new_member', 'new_avatar', 'updated_profile', 'friendship_created' ), true ) ) { + return ''; + } + + $content = self::paragraph( wp_rand( 1, 4 ) ); + + // A mention now and then, since resolving them is real work. + if ( 0 === wp_rand( 0, 5 ) && ! empty( $users ) ) { + $user = get_userdata( self::pick( $users ) ); + + if ( $user ) { + $content = '@' . $user->user_login . ' ' . $content; + } + } + + if ( 0 === wp_rand( 0, 7 ) ) { + $content .= ' https://example.com/read/' . wp_rand( 100, 99999 ); + } + + return $content; + } + + /** + * The rendered action line an activity carries. + * + * @since BuddyBoss [BBVERSION] + * + * @param int $user_id Author. + * @param string $type Activity type. + * + * @return string + */ + protected static function action_text( $user_id, $type ) { + $name = bp_core_get_user_displayname( $user_id ); + + switch ( $type ) { + case 'activity_comment': + return sprintf( '%s posted a new activity comment', esc_html( $name ) ); + case 'bbp_topic_create': + return sprintf( '%s started a discussion', esc_html( $name ) ); + case 'new_member': + return sprintf( '%s became a registered member', esc_html( $name ) ); + default: + return sprintf( '%s posted an update', esc_html( $name ) ); + } + } + + /** + * A paragraph of plausible community chatter. + * + * @since BuddyBoss [BBVERSION] + * + * @param int $sentences How many sentences. + * + * @return string + */ + protected static function paragraph( $sentences ) { + $out = array(); + $total = max( 1, (int) $sentences ); + + for ( $i = 0; $i < $total; $i++ ) { + $out[] = self::pick( self::$sentences ); + } + + return implode( ' ', $out ); + } + + // ---- Helpers ---- + + /** + * Members available to the job, falling back to whoever already exists. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * + * @return array Member IDs. + */ + protected static function user_pool( $job ) { + if ( ! empty( $job['created']['user_ids'] ) ) { + return $job['created']['user_ids']; + } + + static $fallback = null; + + if ( null === $fallback ) { + $fallback = get_users( + array( + 'fields' => 'ID', + 'number' => 200, + ) + ); + $fallback = array_map( 'intval', (array) $fallback ); + } + + return $fallback; + } + + /** + * Activities that can take comments and favourites. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $job The job. + * + * @return array Activity IDs. + */ + protected static function parent_pool( $job ) { + static $pool = null; + + if ( null !== $pool ) { + return $pool; + } + + global $wpdb; + + $from = (int) $job['created']['activity_from']; + $to = (int) $job['created']['activity_to']; + + if ( $from < 1 || $to < $from ) { + return array(); + } + + $sql = $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}bp_activity WHERE id BETWEEN %d AND %d AND type = 'activity_update' ORDER BY id ASC LIMIT 4000", + $from, + $to + ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + // Likewise: freshly written rows, read once and held in a static. + $ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + $pool = array_map( 'intval', (array) $ids ); + + return $pool; + } + + /** + * Whether a table is present. + * + * @since BuddyBoss [BBVERSION] + * + * @param string $table Table name. + * + * @return bool + */ + protected static function table_exists( $table ) { + global $wpdb; + + return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + } + + /** + * A datetime some days in the past. + * + * Activities are spread across a long window on purpose: a feed whose rows + * all share a timestamp sorts and paginates nothing like a real one. + * + * @since BuddyBoss [BBVERSION] + * + * @param int $days_ago How many days back. + * @param bool $jitter Optional. Scatter within the day. Default false. + * + * @return string MySQL datetime. + */ + protected static function past_datetime( $days_ago, $jitter = false ) { + $stamp = time() - ( (int) $days_ago * DAY_IN_SECONDS ); + + if ( $jitter ) { + $stamp -= wp_rand( 0, DAY_IN_SECONDS ); + } + + return gmdate( 'Y-m-d H:i:s', $stamp ); + } + + /** + * One item from a list, at random. + * + * @since BuddyBoss [BBVERSION] + * + * @param array $choices List to choose from. + * + * @return mixed + */ + protected static function pick( $choices ) { + $choices = array_values( (array) $choices ); + + if ( empty( $choices ) ) { + return ''; + } + + return $choices[ wp_rand( 0, count( $choices ) - 1 ) ]; + } + + /** + * First names. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + protected static $first_names = array( + 'Amara', + 'Bilal', + 'Cora', + 'Dmitri', + 'Elena', + 'Farid', + 'Greta', + 'Hana', + 'Idris', + 'Jonas', + 'Kaya', + 'Lucia', + 'Malik', + 'Nadia', + 'Oscar', + 'Priya', + 'Quinn', + 'Rosa', + 'Samir', + 'Tomas', + 'Ursula', + 'Viktor', + 'Wren', + 'Yusuf', + ); + + /** + * Last names. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + protected static $last_names = array( + 'Abbott', + 'Bergström', + 'Castellanos', + 'Duarte', + 'Eriksen', + 'Fontaine', + 'Grimaldi', + 'Halvorsen', + 'Ivanov', + 'Jankowski', + 'Kovács', + 'Lindqvist', + 'Moreau', + 'Nakamura', + 'Okonkwo', + 'Petrov', + 'Rahman', + 'Sørensen', + ); + + /** + * Words that start a group name. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + protected static $group_words = array( + 'Weekend', + 'Northside', + 'Open', + 'Quiet', + 'First', + 'Coastal', + 'Winter', + 'Practical', + 'Late Night', + 'Sunday', + 'Downtown', + 'Amateur', + ); + + /** + * Words that finish a group name. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + protected static $group_nouns = array( + 'Runners', + 'Readers', + 'Builders', + 'Cooks', + 'Photographers', + 'Gardeners', + 'Cyclists', + 'Writers', + 'Woodworkers', + 'Birdwatchers', + 'Climbers', + ); + + /** + * Sentences the generated posts are assembled from. + * + * @since BuddyBoss [BBVERSION] + * + * @var array + */ + protected static $sentences = array( + 'Finally got the back garden cleared out this weekend.', + 'Does anyone here have a recommendation for a decent second-hand shop nearby?', + 'Six months in and I am still learning something new every week.', + 'Took the long route home and it was absolutely worth it.', + 'Thanks to everyone who turned up on Saturday, that was a good turnout.', + 'Reposting this because the first one went up with the wrong date.', + 'Small win: the thing I have been putting off for a month is done.', + 'If anyone is free Thursday evening there is a spare place going.', + 'Not sure this is the right group for it, but worth asking.', + 'Update on the earlier post -- it turned out to be much simpler than I thought.', + 'Third attempt at this and I think I have finally got it right.', + 'Genuinely surprised by how much difference the small change made.', + 'Question for the more experienced folks here before I commit to anything.', + 'Photos from last month, finally sorted through them.', + 'Starting again from scratch after the last attempt went sideways.', + 'Whoever suggested the earlier start time was completely right.', + ); +} diff --git a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php index 887875dd048..a6431bb6255 100644 --- a/src/bp-activity/classes/class-bp-rest-activity-endpoint.php +++ b/src/bp-activity/classes/class-bp-rest-activity-endpoint.php @@ -1237,7 +1237,8 @@ public function update_item_permissions_check( $request ) { ); if ( is_user_logged_in() ) { - $activity = $this->get_activity_object( $request ); + // Neither this check nor the ones it defers to reads the comments. + $activity = $this->get_activity_object( $request, false ); $user_id = ! empty( $request->get_param( 'user_id' ) ) ? (int) $request->get_param( 'user_id' ) : bp_loggedin_user_id(); $item_id = ! empty( $request->get_param( 'primary_item_id' ) ) ? (int) $request->get_param( 'primary_item_id' ) : 0; $component = ! empty( $request->get_param( 'component' ) ) ? $request->get_param( 'component' ) : 'activity'; @@ -1468,7 +1469,8 @@ public function delete_item_permissions_check( $request ) { ); if ( is_user_logged_in() ) { - $activity = $this->get_activity_object( $request ); + // `bp_activity_user_can_delete()` reads the author and component, not the comments. + $activity = $this->get_activity_object( $request, false ); if ( empty( $activity->id ) ) { $retval = new WP_Error( @@ -2128,30 +2130,49 @@ public function prepare_item_for_response( $activity, $request ) { $activity_metas = bb_activity_get_metadata( $activity->id ); if ( 'activity_comment' === $activity->type ) { - $can_edit = ( - function_exists( 'bb_is_activity_comment_edit_enabled' ) - && bb_is_activity_comment_edit_enabled() - && function_exists( 'bb_activity_comment_user_can_edit' ) - && bb_activity_comment_user_can_edit( $activity ) - ); - $edited_date = $activity_metas['_is_edited'][0] ?? ''; $edited_date = ! empty( $edited_date ) ? $edited_date : $activity->date_recorded; $date_recorded = bp_rest_prepare_date_response( $edited_date ); } else { - $can_edit = ( - function_exists( 'bp_is_activity_edit_enabled' ) - && bp_is_activity_edit_enabled() - && function_exists( 'bp_activity_user_can_edit' ) - && bp_activity_user_can_edit( $activity ) - ) && ( - isset( $activity->privacy ) && - ! in_array( $activity->privacy, array( 'document', 'media', 'video' ), true ) - ); - $date_recorded = bp_rest_prepare_date_response( $activity->date_recorded ); } + /* + * Whether this user may edit the activity, worked out on demand. + * + * The answer costs a settings read and a capability check per item, and + * several fields want it, so it is resolved once and only if one of them + * is actually being built. + */ + $can_edit = null; + + $resolve_can_edit = function () use ( $activity, &$can_edit ) { + if ( null !== $can_edit ) { + return $can_edit; + } + + if ( 'activity_comment' === $activity->type ) { + $can_edit = ( + function_exists( 'bb_is_activity_comment_edit_enabled' ) + && bb_is_activity_comment_edit_enabled() + && function_exists( 'bb_activity_comment_user_can_edit' ) + && bb_activity_comment_user_can_edit( $activity ) + ); + } else { + $can_edit = ( + function_exists( 'bp_is_activity_edit_enabled' ) + && bp_is_activity_edit_enabled() + && function_exists( 'bp_activity_user_can_edit' ) + && bp_activity_user_can_edit( $activity ) + ) && ( + isset( $activity->privacy ) && + ! in_array( $activity->privacy, array( 'document', 'media', 'video' ), true ) + ); + } + + return $can_edit; + }; + /* * The fields the request asked for. When the request carries no * `_fields`, this is every property of the item schema, so each of the @@ -2210,26 +2231,53 @@ function_exists( 'bp_is_activity_edit_enabled' ) $data['primary_item_id'] = $activity->item_id; $data['secondary_item_id'] = $activity->secondary_item_id; $data['status'] = $activity->is_spam ? 'spam' : $activity->status; - $data['title'] = $this->bb_rest_activity_action( $activity->action, $activity ); - $data['type'] = $activity->type; + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = $this->bb_rest_activity_action( $activity->action, $activity ); + } + + $data['type'] = $activity->type; if ( rest_is_field_included( 'favorited', $fields ) ) { $data['favorited'] = in_array( $activity->id, $this->get_user_favorites( $activity ), true ); } // extend response. - $data['can_favorite'] = ( 'activity_comment' === $activity->type ) ? bb_activity_comment_can_favorite() : bp_activity_can_favorite(); + if ( rest_is_field_included( 'can_favorite', $fields ) ) { + $data['can_favorite'] = ( 'activity_comment' === $activity->type ) ? bb_activity_comment_can_favorite() : bp_activity_can_favorite(); + } if ( rest_is_field_included( 'favorite_count', $fields ) ) { $data['favorite_count'] = $this->get_activity_favorite_count( $activity ); } - $data['can_comment'] = ( 'activity_comment' === $activity->type ) ? bp_activity_can_comment_reply( $activity ) : bp_activity_can_comment(); - $data['can_edit'] = $can_edit; - $data['is_edited'] = $activity_metas['_is_edited'][0] ?? ''; - $data['can_delete'] = bp_activity_user_can_delete( $activity ); - $data['content_stripped'] = html_entity_decode( wp_strip_all_tags( $activity->content ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ); - $data['privacy'] = ( isset( $activity->privacy ) ? $activity->privacy : false ); + /* + * Each of these costs a capability check or a pass over the content, so + * none of them runs for a request that did not ask for it. The plain + * scalars above are left ungated on purpose: they cost nothing to copy, + * and fields registered with `bp_rest_register_field()` read them off + * the prepared item. + */ + if ( rest_is_field_included( 'can_comment', $fields ) ) { + $data['can_comment'] = ( 'activity_comment' === $activity->type ) ? bp_activity_can_comment_reply( $activity ) : bp_activity_can_comment(); + } + + if ( rest_is_field_included( 'can_edit', $fields ) ) { + $data['can_edit'] = $resolve_can_edit(); + } + + if ( rest_is_field_included( 'is_edited', $fields ) ) { + $data['is_edited'] = $activity_metas['_is_edited'][0] ?? ''; + } + + if ( rest_is_field_included( 'can_delete', $fields ) ) { + $data['can_delete'] = bp_activity_user_can_delete( $activity ); + } + + if ( rest_is_field_included( 'content_stripped', $fields ) ) { + $data['content_stripped'] = html_entity_decode( wp_strip_all_tags( $activity->content ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ); + } + + $data['privacy'] = ( isset( $activity->privacy ) ? $activity->privacy : false ); if ( rest_is_field_included( 'activity_data', $fields ) ) { $data['activity_data'] = $this->bp_rest_activitiy_edit_data( $activity ); @@ -2380,9 +2428,17 @@ function_exists( 'bb_is_close_activity_comments_enabled' ) && ! empty( $secondary_activity->privacy ) && in_array( $secondary_activity->privacy, array( 'media', 'document', 'video' ), true ) ) { - $data['can_comment'] = false; - $data['can_edit'] = false; - $data['can_favorite'] = false; + if ( array_key_exists( 'can_comment', $data ) ) { + $data['can_comment'] = false; + } + + if ( array_key_exists( 'can_edit', $data ) ) { + $data['can_edit'] = false; + } + + if ( array_key_exists( 'can_favorite', $data ) ) { + $data['can_favorite'] = false; + } } } } @@ -2834,7 +2890,8 @@ protected function can_see( $request ) { } } - $activity = $this->get_activity_object( $request ); + // A read check never looks at the comments, so it does not pay for them. + $activity = $this->get_activity_object( $request, false ); return ( ! empty( $activity ) ? bp_activity_user_can_read( $activity, bp_loggedin_user_id() ) : false ); } @@ -2877,18 +2934,31 @@ protected function show_hidden( $component, $item_id ) { /** * Get activity object. * - * @param WP_REST_Request $request Full details about the request. + * Permission checks ask for the activity so they can read its author, + * privacy and component. None of them look at its comments, and fetching + * the comment tree is by some distance the most expensive part of the + * fetch. WordPress asks each of these callbacks once per item to fill in + * the `targetHints` on that item's `self` link, so on a page of twenty the + * saving is twenty comment trees that were built and thrown away. + * + * @param WP_REST_Request|int $request Full details about the request, + * or an activity ID. + * @param bool $with_comments Optional. Whether the comment + * tree is needed. Default true, + * which is what every caller got + * before this argument existed. * * @return BP_Activity_Activity|string An activity object. * @since 0.1.0 + * @since BuddyBoss [BBVERSION] Added the `$with_comments` argument. */ - public function get_activity_object( $request ) { + public function get_activity_object( $request, $with_comments = true ) { $activity_id = is_numeric( $request ) ? $request : (int) $request['id']; $activity = bp_activity_get_specific( array( 'activity_ids' => array( $activity_id ), - 'display_comments' => true, + 'display_comments' => (bool) $with_comments, 'status' => ! empty( $request['activity_status'] ) ? $request['activity_status'] : false, ) ); diff --git a/src/bp-loader.php b/src/bp-loader.php index 6892050e6cb..81eb3a40525 100644 --- a/src/bp-loader.php +++ b/src/bp-loader.php @@ -355,6 +355,9 @@ function buddypress() { // load the member switch class so all the hook prior to bp_init can be hook in. require dirname( __FILE__ ) . '/bp-members/classes/class-bp-core-members-switching.php'; + // TEMPORARY: selective-fields performance tooling. Delete this line and `src/bb-perf-lab/` to remove it. + require dirname( __FILE__ ) . '/bb-perf-lab/bb-perf-lab.php'; + /* * Hook BuddyPress early onto the 'plugins_loaded' action. * diff --git a/tests/phpunit/testcases/activity/rest-fields.php b/tests/phpunit/testcases/activity/rest-fields.php index 6fd0131d66c..c38d0f0db80 100644 --- a/tests/phpunit/testcases/activity/rest-fields.php +++ b/tests/phpunit/testcases/activity/rest-fields.php @@ -133,6 +133,13 @@ public static function guarded_field_provider() { array( 'can_toggle_notification' ), array( 'is_receive_notification' ), array( 'bb_activity_post_feature_image' ), + array( 'can_edit' ), + array( 'can_delete' ), + array( 'can_comment' ), + array( 'can_favorite' ), + array( 'content_stripped' ), + array( 'title' ), + array( 'is_edited' ), ); } @@ -769,6 +776,110 @@ public function test_activity_comment_endpoint_returns_whole_comments() { } } + /** + * Capability checks that used to run for every activity whatever the + * request asked for. + * + * Each costs a settings read and a permission check per item, so on a page + * of twenty they were twenty checks nobody had asked for. + * + * @return array + */ + public static function permission_field_provider() { + return array( + 'can_delete' => array( 'can_delete', 'bp_activity_user_can_delete' ), + 'can_comment' => array( 'can_comment', 'bp_activity_can_comment' ), + 'can_edit' => array( 'can_edit', 'bp_activity_user_can_edit' ), + ); + } + + /** + * A permission the request did not ask about must not be worked out. + * + * @dataProvider permission_field_provider + * + * @param string $field Field name. + * @param string $hook Filter the permission check fires. + */ + public function test_permission_is_not_resolved_when_its_field_is_not_selected( $field, $hook ) { + $this->assertSame( 0, $this->count_hook( $hook, array( '_fields' => 'id' ) ) ); + } + + /** + * ...and one it did ask about still is. + * + * @dataProvider permission_field_provider + * + * @param string $field Field name. + * @param string $hook Filter the permission check fires. + */ + public function test_permission_is_resolved_when_its_field_is_selected( $field, $hook ) { + $this->assertGreaterThan( 0, $this->count_hook( $hook, array( '_fields' => 'id,' . $field ) ) ); + } + + /** + * `can_favorite` is a special case: `prepare_links()` asks the same question + * to decide whether to offer the favourite link, and that is link building + * rather than field building. So the field's own check has to show up as an + * increase over what the links already cost, not as a count from zero. + */ + public function test_favourite_permission_is_only_resolved_once_more_when_selected() { + $without = $this->count_hook( 'bp_activity_can_favorite', array( '_fields' => 'id' ) ); + $with = $this->count_hook( 'bp_activity_can_favorite', array( '_fields' => 'id,can_favorite' ) ); + + $this->assertGreaterThan( $without, $with ); + } + + /** + * Count how many times a hook fires while a collection request is served. + * + * WordPress asks every route's permission callback for each `self` link, to + * fill in the `targetHints` it attaches. That happens outside the + * controller and would swamp what is being measured here, so the hint is + * declared up front for the duration of the count, which is what makes + * WordPress skip the probe. + * + * @param string $hook Hook name. + * @param array $params Request parameters. + * + * @return int + */ + protected function count_hook( $hook, $params ) { + $calls = 0; + + $counter = function ( $value ) use ( &$calls ) { + $calls++; + + return $value; + }; + + $declare_hints = function ( $links ) { + $links['self']['targetHints'] = array( 'allow' => array( 'GET' ) ); + + return $links; + }; + + add_filter( $hook, $counter ); + add_filter( 'bp_rest_activity_prepare_links', $declare_hints, 999 ); + + /* + * The edit check is only reached when editing is switched on, and a + * fresh install has it off. Without this the `can_edit` case would + * short-circuit and prove nothing either way. + */ + add_filter( 'bp_is_activity_edit_enabled', '__return_true' ); + + try { + $this->get_first_item( $params ); + } finally { + remove_filter( $hook, $counter ); + remove_filter( 'bp_rest_activity_prepare_links', $declare_hints, 999 ); + remove_filter( 'bp_is_activity_edit_enabled', '__return_true' ); + } + + return $calls; + } + /** * Add a comment to the fixture activity. * From f1189010f5d59df71c33a1e44990b0f84aa2a769 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Tue, 25 Aug 2026 16:14:49 +0200 Subject: [PATCH 7/8] Measure and optimise --- src/bb-perf-lab/bb-perf-lab.php | 48 +++++++++++++++++++ .../classes/class-bb-perf-lab-bench.php | 4 +- .../classes/class-bb-perf-lab-monitor.php | 4 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/bb-perf-lab/bb-perf-lab.php b/src/bb-perf-lab/bb-perf-lab.php index 711b9dbe875..58993eafd03 100644 --- a/src/bb-perf-lab/bb-perf-lab.php +++ b/src/bb-perf-lab/bb-perf-lab.php @@ -96,6 +96,54 @@ function bb_perf_lab_setting( $key = '', $fallback = null ) { return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback; } +/** + * Begin a memory measurement, and return the baseline to compare against. + * + * `memory_get_peak_usage()` is a high-water mark for the whole process and only + * ever climbs, so subtracting a "before" from an "after" reports zero for every + * measurement after the first -- which is exactly what the benchmark's memory + * column was doing. PHP 8.2 can reset the mark, which gives a true peak per + * measurement. Below that, the best available answer is the change in currently + * allocated memory, which understates the peak but at least varies with the + * work. + * + * @since BuddyBoss [BBVERSION] + * + * @return int Baseline to pass to `bb_perf_lab_memory_used()`. + */ +function bb_perf_lab_memory_start() { + /* + * `real_usage` is deliberately off: it reports whole chunks the allocator + * took from the system, which move in megabytes and stay flat across + * anything smaller. What is wanted here is what the request itself + * allocated, so PHP's own accounting is the right one to read. + */ + if ( function_exists( 'memory_reset_peak_usage' ) ) { + memory_reset_peak_usage(); + } + + return memory_get_usage(); +} + +/** + * Finish a memory measurement. + * + * @since BuddyBoss [BBVERSION] + * + * @param int $baseline Value returned by `bb_perf_lab_memory_start()`. + * + * @return int Bytes used by the measured work. + */ +function bb_perf_lab_memory_used( $baseline ) { + if ( function_exists( 'memory_reset_peak_usage' ) ) { + // The mark was reset while the baseline was already allocated, so the + // peak it has climbed to since is that baseline plus this work. + return max( 0, memory_get_peak_usage() - (int) $baseline ); + } + + return max( 0, memory_get_usage() - (int) $baseline ); +} + /** * Boot the Performance Lab. * diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php b/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php index a8ce1e22f98..1f8b3199ac3 100644 --- a/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-bench.php @@ -204,7 +204,7 @@ protected static function measure( $args, $selection, $flush ) { $saved_from = ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) ? count( $wpdb->queries ) : 0; $queries_at = (int) $wpdb->num_queries; - $mem_at = memory_get_peak_usage( true ); + $mem_at = bb_perf_lab_memory_start(); $started = microtime( true ); $response = rest_do_request( $request ); @@ -227,7 +227,7 @@ protected static function measure( $args, $selection, $flush ) { $data = $server->response_to_data( $response, empty( $embed ) ? false : $embed ); $payload = strlen( (string) wp_json_encode( $data ) ); $wall = ( microtime( true ) - $started ) * 1000; - $mem = memory_get_peak_usage( true ) - $mem_at; + $mem = bb_perf_lab_memory_used( $mem_at ); $count = (int) $wpdb->num_queries - $queries_at; $db_ms = 0.0; diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php b/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php index ff0fb201484..019ec54b9a5 100644 --- a/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-monitor.php @@ -214,7 +214,7 @@ public function begin( $result, $server, $request ) { 'time' => microtime( true ), 'queries' => (int) $wpdb->num_queries, 'saved' => ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) ? count( $wpdb->queries ) : 0, - 'mem' => memory_get_peak_usage( true ), + 'mem' => bb_perf_lab_memory_start(), ); return $result; @@ -240,7 +240,7 @@ public function finish( $served, $server, $request ) { $wall = ( microtime( true ) - $this->start['time'] ) * 1000; $count = (int) $wpdb->num_queries - $this->start['queries']; - $mem = memory_get_peak_usage( true ) - $this->start['mem']; + $mem = bb_perf_lab_memory_used( $this->start['mem'] ); list( $db_ms, $slow ) = $this->query_timings( $this->start['saved'] ); From c11a8a8be48e49f924bcaaf544409ee4bc9e26e6 Mon Sep 17 00:00:00 2001 From: Konrad Karauda Date: Tue, 25 Aug 2026 17:02:23 +0200 Subject: [PATCH 8/8] Improve members --- src/bb-perf-lab/bb-perf-lab.php | 7 + .../classes/class-bb-perf-lab-rest.php | 213 ++++++++++++++++++ .../classes/class-bp-rest-groups-endpoint.php | 23 +- .../class-bp-rest-members-endpoint.php | 40 ++-- 4 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 src/bb-perf-lab/classes/class-bb-perf-lab-rest.php diff --git a/src/bb-perf-lab/bb-perf-lab.php b/src/bb-perf-lab/bb-perf-lab.php index 58993eafd03..bd91c59a937 100644 --- a/src/bb-perf-lab/bb-perf-lab.php +++ b/src/bb-perf-lab/bb-perf-lab.php @@ -61,6 +61,7 @@ require_once BB_PERF_LAB_DIR . '/classes/class-bb-perf-lab-seeder.php'; require_once BB_PERF_LAB_DIR . '/classes/class-bb-perf-lab-bench.php'; require_once BB_PERF_LAB_DIR . '/classes/class-bb-perf-lab-admin.php'; +require_once BB_PERF_LAB_DIR . '/classes/class-bb-perf-lab-rest.php'; /** * Read the Performance Lab settings. @@ -161,5 +162,11 @@ function bb_perf_lab_init() { if ( is_admin() ) { BB_Perf_Lab_Admin::instance(); } + + /* + * Application Passwords authenticate REST but not `admin-ajax`, so the + * benchmark is reachable here and nowhere else when driven from a script. + */ + add_action( 'rest_api_init', array( 'BB_Perf_Lab_REST', 'register_routes' ) ); } add_action( 'plugins_loaded', 'bb_perf_lab_init', 1 ); diff --git a/src/bb-perf-lab/classes/class-bb-perf-lab-rest.php b/src/bb-perf-lab/classes/class-bb-perf-lab-rest.php new file mode 100644 index 00000000000..1e5dc02f7f5 --- /dev/null +++ b/src/bb-perf-lab/classes/class-bb-perf-lab-rest.php @@ -0,0 +1,213 @@ + WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'bench' ), + 'permission_callback' => array( __CLASS__, 'permissions' ), + 'args' => self::bench_args(), + ), + ) + ); + + register_rest_route( + $namespace, + '/perf-lab/environment', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'environment' ), + 'permission_callback' => array( __CLASS__, 'permissions' ), + ), + ) + ); + } + + /** + * Arguments the benchmark route accepts. + * + * @since BuddyBoss [BBVERSION] + * + * @return array + */ + protected static function bench_args() { + return array( + 'route' => array( + 'description' => __( 'Route to dispatch, e.g. /buddyboss/v1/activity.', 'buddyboss' ), + 'type' => 'string', + 'default' => '/buddyboss/v1/activity', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'query' => array( + 'description' => __( 'Query string for the dispatch, without the field selection.', 'buddyboss' ), + 'type' => 'string', + 'default' => 'per_page=20', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'fields' => array( + 'description' => __( 'The `_fields` under test.', 'buddyboss' ), + 'type' => 'string', + 'default' => 'id', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'embed' => array( + 'description' => __( 'Relations to embed, comma separated.', 'buddyboss' ), + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'embed_fields' => array( + 'description' => __( 'The `embed_fields` under test.', 'buddyboss' ), + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'runs' => array( + 'description' => __( 'Iterations per arm.', 'buddyboss' ), + 'type' => 'integer', + 'default' => 7, + 'sanitize_callback' => 'absint', + ), + 'flush' => array( + 'description' => __( 'Flush the object cache before each run.', 'buddyboss' ), + 'type' => 'boolean', + 'default' => true, + ), + 'user_id' => array( + 'description' => __( 'Run as this member. 0 runs as the caller.', 'buddyboss' ), + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + ); + } + + /** + * Only administrators, as on the screen. + * + * @since BuddyBoss [BBVERSION] + * + * @return true|WP_Error + */ + public static function permissions() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'bb_perf_lab_forbidden', + __( 'Sorry, you are not allowed to do that.', 'buddyboss' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Run the benchmark. + * + * @since BuddyBoss [BBVERSION] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error + */ + public static function bench( $request ) { + $result = BB_Perf_Lab_Bench::run( + array( + 'route' => $request->get_param( 'route' ), + 'query' => $request->get_param( 'query' ), + 'fields' => $request->get_param( 'fields' ), + 'embed' => $request->get_param( 'embed' ), + 'embed_fields' => $request->get_param( 'embed_fields' ), + 'runs' => $request->get_param( 'runs' ), + 'flush' => (bool) $request->get_param( 'flush' ), + 'user_id' => $request->get_param( 'user_id' ), + ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return rest_ensure_response( $result ); + } + + /** + * What the numbers were taken on, so a report can say so. + * + * @since BuddyBoss [BBVERSION] + * + * @return WP_REST_Response + */ + public static function environment() { + global $wpdb, $wp_version; + + $counts = array(); + + foreach ( array( 'bp_activity', 'bp_activity_meta', 'bp_groups', 'bp_groups_members', 'bp_follow', 'bp_friends' ) as $table ) { + $full = $wpdb->prefix . $table; + + if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $full ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + continue; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $counts[ $table ] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$full}" ); + } + + $counts['users'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + return rest_ensure_response( + array( + 'wordpress' => $wp_version, + 'php' => PHP_VERSION, + 'platform' => defined( 'BP_PLATFORM_VERSION' ) ? BP_PLATFORM_VERSION : '', + 'mysql' => $wpdb->db_version(), + 'object_cache' => (bool) wp_using_ext_object_cache(), + 'savequeries' => defined( 'SAVEQUERIES' ) && SAVEQUERIES, + 'opcache' => function_exists( 'opcache_get_status' ) && is_array( @opcache_get_status( false ) ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + 'active_plugins' => count( (array) get_option( 'active_plugins', array() ) ), + 'rows' => $counts, + ) + ); + } +} diff --git a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php index 4e2b71f91f3..795d16a1040 100644 --- a/src/bp-groups/classes/class-bp-rest-groups-endpoint.php +++ b/src/bp-groups/classes/class-bp-rest-groups-endpoint.php @@ -982,15 +982,22 @@ function_exists( 'bb_can_user_create_poll_activity' ) && ); } - // Cover Image. + /* + * Cover image. + * + * Deliberately NOT gated on `_fields`. Consumers downstream of this + * controller read `cover_url` off the prepared group, and when it is + * absent they resolve it themselves -- once per group, against the + * attachment store. On a hosted install that is roughly 107 ms an item, + * so a page of twenty that omitted the field answered in ~2.9 s where + * the same page including it answered in ~0.7 s. Declining to build a + * field must never cost more than building it, and here it did. + * + * Measured on the dev host, 25 August 2026. + */ if ( ! empty( $schema['properties']['cover_url'] ) && function_exists( 'bp_get_group_cover_url' ) ) { - if ( rest_is_field_included( 'cover_url', $fields ) ) { - $data['cover_url'] = bp_get_group_cover_url( $item ); - } - - if ( rest_is_field_included( 'cover_is_default', $fields ) ) { - $data['cover_is_default'] = ! bp_attachments_get_group_has_cover_image( $item->id ); - } + $data['cover_url'] = bp_get_group_cover_url( $item ); + $data['cover_is_default'] = ! bp_attachments_get_group_has_cover_image( $item->id ); } if ( rest_is_field_included( 'forum', $fields ) && $this->bp_rest_group_is_forum_enabled( $item ) && function_exists( 'bbpress' ) ) { diff --git a/src/bp-members/classes/class-bp-rest-members-endpoint.php b/src/bp-members/classes/class-bp-rest-members-endpoint.php index 145013250f5..57b755376d6 100644 --- a/src/bp-members/classes/class-bp-rest-members-endpoint.php +++ b/src/bp-members/classes/class-bp-rest-members-endpoint.php @@ -923,24 +923,32 @@ function_exists( 'bp_is_following' ) } } - // Cover Image. - if ( rest_is_field_included( 'cover_url', $fields ) ) { - $data['cover_url'] = ( - empty( bp_disable_cover_image_uploads() ) - ? bp_attachments_get_attachment( - 'url', - array( - 'object_dir' => 'members', - 'item_id' => $user->ID, - ) + /* + * Cover image. + * + * Deliberately NOT gated on `_fields`. Consumers downstream of this + * controller read `cover_url` off the prepared member, and when it is + * absent they resolve it themselves -- once per member, against the + * attachment store. On a hosted install that is roughly 107 ms an item, + * so a page of twenty that omitted the field answered in ~2.9 s where + * the same page including it answered in ~0.7 s. Declining to build a + * field must never cost more than building it, and here it did. + * + * Measured on the dev host, 25 August 2026. + */ + $data['cover_url'] = ( + empty( bp_disable_cover_image_uploads() ) + ? bp_attachments_get_attachment( + 'url', + array( + 'object_dir' => 'members', + 'item_id' => $user->ID, ) - : false - ); - } + ) + : false + ); - if ( rest_is_field_included( 'cover_is_default', $fields ) ) { - $data['cover_is_default'] = ! bp_attachments_get_user_has_cover_image( $user->ID ); - } + $data['cover_is_default'] = ! bp_attachments_get_user_has_cover_image( $user->ID ); // Fallback. if ( false === $member_types ) {