diff --git a/includes/classes/Indexable/Post/Post.php b/includes/classes/Indexable/Post/Post.php index e77a3c836f..50c1ab1efb 100644 --- a/includes/classes/Indexable/Post/Post.php +++ b/includes/classes/Indexable/Post/Post.php @@ -611,13 +611,13 @@ public function prepare_date_terms( $date_to_prepare ) { } /** - * Prepare terms to send to ES. + * Get an array of taxonomies that are indexable for the given post * + * @since 4.0.0 * @param WP_Post $post Post object - * @since 0.1.0 - * @return array + * @return array Array of WP_Taxonomy objects that should be indexed */ - private function prepare_terms( $post ) { + public function get_indexable_post_taxonomies( $post ) { $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); $selected_taxonomies = []; @@ -635,7 +635,36 @@ private function prepare_terms( $post ) { * @param {WP_Post} Post object * @return {array} New taxonomies */ - $selected_taxonomies = apply_filters( 'ep_sync_taxonomies', $selected_taxonomies, $post ); + $selected_taxonomies = (array) apply_filters( 'ep_sync_taxonomies', $selected_taxonomies, $post ); + + // Important we validate here to ensure there are no invalid taxonomy values returned from the filter, as just one would cause wp_get_object_terms() to fail. + $validated_taxonomies = []; + foreach ( $selected_taxonomies as $selected_taxonomy ) { + // If we get a taxonomy name, we need to convert it to taxonomy object + if ( ! is_object( $selected_taxonomy ) && taxonomy_exists( (string) $selected_taxonomy ) ) { + $selected_taxonomy = get_taxonomy( $selected_taxonomy ); + } + + // We check if the $taxonomy object has a valid name property. Backward compatibility since WP_Taxonomy introduced in WP 4.7 + if ( ! is_a( $selected_taxonomy, '\WP_Taxonomy' ) || ! property_exists( $selected_taxonomy, 'name' ) || ! taxonomy_exists( $selected_taxonomy->name ) ) { + continue; + } + + $validated_taxonomies[] = $selected_taxonomy; + } + + return $validated_taxonomies; + } + + /** + * Prepare terms to send to ES. + * + * @param WP_Post $post Post object + * @since 0.1.0 + * @return array + */ + private function prepare_terms( $post ) { + $selected_taxonomies = $this->get_indexable_post_taxonomies( $post ); if ( empty( $selected_taxonomies ) ) { return []; @@ -653,16 +682,6 @@ private function prepare_terms( $post ) { $allow_hierarchy = apply_filters( 'ep_sync_terms_allow_hierarchy', true ); foreach ( $selected_taxonomies as $taxonomy ) { - // If we get a taxonomy name, we need to convert it to taxonomy object - if ( ! is_object( $taxonomy ) && taxonomy_exists( (string) $taxonomy ) ) { - $taxonomy = get_taxonomy( $taxonomy ); - } - - // We check if the $taxonomy object as name property. Backward compatibility since WP_Taxonomy introduced in WP 4.7 - if ( ! is_a( $taxonomy, '\WP_Taxonomy' ) || ! property_exists( $taxonomy, 'name' ) ) { - continue; - } - $object_terms = get_the_terms( $post->ID, $taxonomy->name ); if ( ! $object_terms || is_wp_error( $object_terms ) ) { diff --git a/includes/classes/Indexable/Post/SyncManager.php b/includes/classes/Indexable/Post/SyncManager.php index 49206b4b2d..72cb7a5253 100644 --- a/includes/classes/Indexable/Post/SyncManager.php +++ b/includes/classes/Indexable/Post/SyncManager.php @@ -61,6 +61,9 @@ public function setup() { // Called just because we need to know somehow if $delete_all is set before action_queue_meta_sync() runs. add_filter( 'delete_post_metadata', array( $this, 'maybe_delete_meta_for_all' ), 10, 5 ); add_action( 'deleted_post_meta', array( $this, 'action_queue_meta_sync' ), 10, 4 ); + add_action( 'set_object_terms', array( $this, 'action_set_object_terms' ), 10, 6 ); + add_action( 'edited_term', array( $this, 'action_edited_term' ), 10, 3 ); + add_action( 'deleted_term_relationships', array( $this, 'action_deleted_term_relationships' ), 10, 3 ); add_action( 'wp_initialize_site', array( $this, 'action_create_blog_index' ) ); add_filter( 'ep_sync_insert_permissions_bypass', array( $this, 'filter_bypass_permission_checks_for_machines' ) ); @@ -315,6 +318,182 @@ public function action_sync_on_update( $post_id ) { } } + /** + * When a post's terms are changed, re-index. + * + * This catches term deletions via wp_delete_term(), because that function internally loops over all attached objects + * and updates their terms. It will also end up firing whenever set_object_terms is called, but the queue will de-duplicate + * multiple instances per post. This won't happen for taxonomies that has a default term (like Uncategorized for categories), + * hence why we also have `action_deleted_term_relationships`. + * + * @see set_object_terms + * @param int $post_id Post ID. + * @param array $terms An array of object terms. + * @param array $tt_ids An array of term taxonomy IDs. + * @param string $taxonomy Taxonomy slug. + * @param bool $append Whether to append new terms to the old terms. + * @param array $old_tt_ids Old array of term taxonomy IDs. + * @since 4.0.0 + */ + public function action_set_object_terms( $post_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) { + if ( $this->kill_sync() ) { + return; + } + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + // Bypass saving if doing autosave + return; + } + + /** + * Filter to allow skipping this action in case of custom handling + * + * @hook ep_skip_action_set_object_terms + * @param {bool} $skip True means kill sync for post + * @param {int} $post_id ID of post + * @param {array} $terms An array of object terms. + * @param {array} $tt_ids An array of term taxonomy IDs. + * @param {string} $taxonomy Taxonomy slug. + * @param {bool} $append Whether to append new terms to the old terms. + * @param {array} $old_tt_ids Old array of term taxonomy IDs. + * @return {boolean} New value + */ + if ( apply_filters( 'ep_skip_action_set_object_terms', false, $post_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) ) { + return; + } + + if ( ! $this->should_reindex_post( $post_id, $taxonomy ) ) { + return; + } + + /** + * Fire before post is queued for syncing + * + * @since 4.0.0 + * @hook ep_sync_on_set_object_terms + * @param {int} $post_id ID of post + * @param {array} $terms An array of object terms. + * @param {array} $tt_ids An array of term taxonomy IDs. + * @param {string} $taxonomy Taxonomy slug. + * @param {bool} $append Whether to append new terms to the old terms. + * @param {array} $old_tt_ids Old array of term taxonomy IDs. + */ + do_action( 'ep_sync_on_set_object_terms', $post_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ); + + $this->add_to_queue( $post_id ); + } + + /** + * When a term is updated, re-index all posts attached to that term + * + * @param int $term_id Term id. + * @param int $tt_id Term Taxonomy id. + * @param string $taxonomy Taxonomy name. + * @since 4.0.0 + */ + public function action_edited_term( $term_id, $tt_id, $taxonomy ) { + global $wpdb; + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + // Bypass saving if doing autosave + return; + } + + // Find ID of all attached posts (query lifted from wp_delete_term()) + $object_ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT object_id FROM $wpdb->term_relationships WHERE term_taxonomy_id = %d", $tt_id ) ); + + if ( ! count( $object_ids ) ) { + return; + } + + /** + * Filter to allow skipping this action in case of custom handling + * + * @hook ep_skip_action_edited_term + * @param {bool} $skip Current value of whether to skip running action_edited_term or not + * @param {int} $term_id Term id. + * @param {int} $tt_id Term Taxonomy id. + * @param {string} $taxonomy Taxonomy name. + * @param {array} $object_ids IDs of the objects attached to the term id. + * @return {bool} New value of whether to skip running action_edited_term or not + */ + if ( apply_filters( 'ep_skip_action_edited_term', false, $term_id, $tt_id, $taxonomy, $object_ids ) ) { + return; + } + + $indexable = Indexables::factory()->get( $this->indexable_slug ); + + // Add all of them to the queue + foreach ( $object_ids as $post_id ) { + if ( ! $this->should_reindex_post( $post_id, $taxonomy ) ) { + continue; + } + + /** + * Fire before post is queued for syncing + * + * @hook ep_sync_on_edited_term + * @param {int} $post_id ID of post + * @param {int} $term_id ID of the term that was edited + * @param {int} $tt_id Taxonomy Term ID of the term that was edited + * @param {int} $taxonomy Taxonomy of the term that was edited + */ + do_action( 'ep_sync_on_edited_term', $post_id, $term_id, $tt_id, $taxonomy ); + + $this->add_to_queue( $post_id ); + } + } + + /** + * When a term relationship is deleted, re-index all posts attached to that term + * + * @param int $post_id Post ID. + * @param array $tt_ids An array of term taxonomy IDs. + * @param string $taxonomy Taxonomy slug. + * @since 4.0.0 + */ + public function action_deleted_term_relationships( $post_id, $tt_ids, $taxonomy ) { + if ( $this->kill_sync() ) { + return; + } + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + // Bypass saving if doing autosave + return; + } + + /** + * Filter to allow skipping this action in case of custom handling + * + * @hook ep_skip_action_deleted_term_relationships + * @param {bool} $skip Current value of whether to skip running action_edited_term or not + * @param {int} $post_id Post ID. + * @param {array} $tt_ids An array of term taxonomy IDs. + * @param {string} $taxonomy Taxonomy slug. + * @return {bool} New value of whether to skip running action_deleted_term_relationships or not + */ + if ( apply_filters( 'ep_skip_action_deleted_term_relationships', false, $post_id, $tt_ids, $taxonomy ) ) { + return; + } + + if ( ! $this->should_reindex_post( $post_id, $taxonomy ) ) { + return; + } + + /** + * Fire before post is queued for syncing + * + * @hook ep_sync_on_deleted_term_relationships + * @since 4.0.0 + * @param {int} $post_id ID of post + * @param {array} $tt_ids An array of term taxonomy IDs. + * @param {string} $taxonomy Taxonomy of the term that was edited + */ + do_action( 'ep_sync_on_deleted_term_relationships', $post_id, $tt_ids, $taxonomy ); + + $this->add_to_queue( $post_id ); + } + /** * Create mapping and network alias when a new blog is created. * @@ -345,4 +524,54 @@ public function action_create_blog_index( $blog ) { restore_current_blog(); } + + /** + * Check if post attributes (post status, taxonomy, and type) match what is needed to reindex or not. + * + * @param int $post_id The post ID. + * @param string $taxonomy The taxonomy slug. + * @return boolean + */ + protected function should_reindex_post( $post_id, $taxonomy ) { + /** + * Filter to kill post sync + * + * @hook ep_post_sync_kill + * @param {bool} $skip True meanas kill sync for post + * @param {int} $object_id ID of post + * @param {int} $object_id ID of post + * @return {boolean} New value + */ + if ( apply_filters( 'ep_post_sync_kill', false, $post_id, $post_id ) ) { + return false; + } + + $post = get_post( $post_id ); + if ( ! is_object( $post ) ) { + return false; + } + + $indexable = Indexables::factory()->get( $this->indexable_slug ); + + // Check post status + $indexable_post_statuses = $indexable->get_indexable_post_status(); + if ( ! in_array( $post->post_status, $indexable_post_statuses, true ) ) { + return false; + } + + // Only re-index if the taxonomy is indexed for this post + $indexable_taxonomies = $indexable->get_indexable_post_taxonomies( $post ); + $indexable_taxonomy_names = wp_list_pluck( $indexable_taxonomies, 'name' ); + if ( ! in_array( $taxonomy, $indexable_taxonomy_names, true ) ) { + return false; + } + + // Check post type + $indexable_post_types = $indexable->get_indexable_post_types(); + if ( ! in_array( $post->post_type, $indexable_post_types, true ) ) { + return false; + } + + return true; + } } diff --git a/tests/php/indexables/TestPost.php b/tests/php/indexables/TestPost.php index 13e4244d68..d936c80c16 100644 --- a/tests/php/indexables/TestPost.php +++ b/tests/php/indexables/TestPost.php @@ -6711,4 +6711,74 @@ public function testInsertPostAndDeleteAnother() { $this->assertEquals( 1, $query->found_posts ); $this->assertEquals( $query->posts[0]->ID, $new_post_id ); } + + /** + * Tests term deletion applied to posts + * + * @return void + * @group post + */ + public function testPostDeletedTerm() { + $cat = wp_create_category( 'test category' ); + $tag = wp_insert_category( [ 'taxonomy' => 'post_tag', 'cat_name' => 'test-tag' ] ); + + $post_id = Functions\create_and_sync_post( + array( + 'tags_input' => array( $tag ), + 'post_category' => array( $cat ), + ) + ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $document = ElasticPress\Indexables::factory()->get( 'post' )->get( $post_id ); + $this->assertNotEmpty( $document['terms']['category'] ); + $this->assertNotEmpty( $document['terms']['post_tag'] ); + + ElasticPress\Indexables::factory()->get( 'post' )->sync_manager->sync_queue = []; + + wp_delete_term( $tag, 'post_tag' ); + wp_delete_term( $cat, 'category' ); + + ElasticPress\Indexables::factory()->get( 'post' )->sync_manager->index_sync_queue(); + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $document = ElasticPress\Indexables::factory()->get( 'post' )->get( $post_id ); + // Category will fallback to Uncategorized. + $this->assertNotContains( $cat, wp_list_pluck( $document['terms']['category'], 'term_id' ) ); + $this->assertArrayNotHasKey( 'post_tag', $document['terms'] ); + } + + /** + * Tests term edition applied to posts + * + * @return void + * @group post + */ + public function testPostEditedTerm() { + $post_id = Functions\create_and_sync_post( + array( + 'tags_input' => array( 'test-tag' ), + ) + ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $test_tag = get_term_by( 'name', 'test-tag', 'post_tag' ); + wp_update_term( + $test_tag->term_id, + 'post_tag', + [ + 'slug' => 'different-tag-slug', + 'name' => 'Different Tag Name', + ] + ); + + ElasticPress\Indexables::factory()->get( 'post' )->sync_manager->index_sync_queue(); + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $document = ElasticPress\Indexables::factory()->get( 'post' )->get( $post_id ); + $this->assertEquals( 'different-tag-slug', $document['terms']['post_tag'][0]['slug'] ); + $this->assertEquals( 'Different Tag Name', $document['terms']['post_tag'][0]['name'] ); + } }