Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update posts on term changes #2603

Merged
merged 4 commits into from
Mar 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions includes/classes/Indexable/Post/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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 [];
Expand All @@ -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 ) ) {
Expand Down
229 changes: 229 additions & 0 deletions includes/classes/Indexable/Post/SyncManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
70 changes: 70 additions & 0 deletions tests/php/indexables/TestPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}
}