<?php
/**
* Integrate with WP_Query
*
* @since 1.0
* @package elasticpress
*/
namespace ElasticPress\Indexable\Post;
use WP_Query;
use ElasticPress\Indexables;
use ElasticPress\Utils;
if ( ! defined( 'ABSPATH' ) ) {
// @codeCoverageIgnoreStart
exit; // Exit if accessed directly.
// @codeCoverageIgnoreEnd
}
/**
* Query integration class
*/
class QueryIntegration {
/**
* Is set only when we switch_to_blog in MS context
*
* @var boolean
*/
private $switched = false;
/**
* Checks to see if we should be integrating and if so, sets up the appropriate actions and filters.
*
* @param string $indexable_slug Indexable slug. Optional.
*
* @since 0.9
* @since 3.6.0 Added $indexable_slug
*/
public function __construct( $indexable_slug = 'post' ) {
/**
* Filter whether to enable query integration during indexing
*
* @since 4.5.2
* @hook ep_enable_query_integration_during_indexing
*
* @param {bool} $enable To allow query integration during indexing
* @param {string} $indexable_slug Indexable slug
* @return {bool} New value
*/
$allow_query_integration_during_indexing = apply_filters( 'ep_enable_query_integration_during_indexing', false, $indexable_slug );
// Ensure that we are currently allowing ElasticPress to override the normal WP_Query
// Indexable->is_full_reindexing() is not available at this point yet, so using the IndexHelper version of it.
if ( \ElasticPress\IndexHelper::factory()->is_full_reindexing( $indexable_slug, get_current_blog_id() ) && ! $allow_query_integration_during_indexing ) {
return;
}
// Add header
add_action( 'pre_get_posts', array( $this, 'add_es_header' ), 5 );
// Query ES for posts
add_filter( 'posts_pre_query', array( $this, 'get_es_posts' ), 10, 2 );
// Properly restore blog if necessary
add_action( 'loop_end', array( $this, 'maybe_restore_blog' ), 10, 1 );
// Properly switch to blog if necessary
add_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
// Sets the correct value for found_posts
add_filter( 'found_posts', array( $this, 'found_posts' ), 10, 2 );
}
/**
* Set the found_posts variable on WP_Query.
*
* @param int $found_posts Number of found posts
* @param WP_Query $query Query object
* @since 2.8.2
* @return int
*/
public function found_posts( $found_posts, $query ) {
/**
* Filter to skip WP Query integration
*
* @hook ep_skip_query_integration
* @param {bool} $skip True to skip
* @param {WP_Query} $query WP Query to evaluate
* @return {bool} New skip value
*/
if ( ( isset( $query->elasticsearch_success ) && false === $query->elasticsearch_success ) || ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) ) {
return $found_posts;
}
return $query->num_posts;
}
/**
* Disables cache_results, adds header.
*
* @param WP_Query $query WP_Query instance
* @since 0.9
*/
public function add_es_header( $query ) {
/**
* Filter to skip WP Query integration
*
* @hook ep_skip_query_integration
* @param {bool} $skip True to skip
* @param {WP_Query} $query WP Query to evaluate
* @return {bool} New skip value
*/
if ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) {
return;
}
/**
* `cache_results` defaults to false but can be enabled.
*
* @since 1.5
*/
$query->set( 'cache_results', false );
if ( ! empty( $query->query['cache_results'] ) ) {
$query->set( 'cache_results', true );
}
if ( ! headers_sent() ) {
/**
* Manually setting a header as $wp_query isn't yet initialized when we
* call: add_filter('wp_headers', 'filter_wp_headers');
*/
// @codeCoverageIgnoreStart
header( 'X-ElasticPress-Query: true' );
// @codeCoverageIgnoreEnd
}
}
/**
* Gets the blog ID that the class is currently switched to.
*
* @return int
*/
public function get_switched() {
return $this->switched;
}
/**
* Switch to the correct site if the post site id is different than the actual one.
*
* Note: This function can bring a performance penalty in multisites with a high number of sites.
*
* @param WP_Post $post Post object
* @param WP_Query $query WP_Query instance. If null, the global query will be used.
* @since 0.9
* @since 3.6.2 `$query` parameter added.
*/
public function maybe_switch_to_blog( $post, $query = null ) {
global $wp_query;
if ( ! $query ) {
$query = $wp_query;
}
if ( ! is_multisite() ) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
if ( ! empty( $post->site_id ) && get_current_blog_id() !== $post->site_id ) {
if ( $this->switched ) {
restore_current_blog();
$this->switched = false;
}
switch_to_blog( $post->site_id );
$this->switched = $post->site_id;
remove_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
setup_postdata( $post );
add_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
if ( $this->switched && ! $query->in_the_loop ) {
restore_current_blog();
$this->switched = false;
}
}
}
/**
* Make sure the correct blog is restored
*
* @param WP_Query $query WP_Query instance
* @since 0.9
*/
public function maybe_restore_blog( $query ) {
if ( ! is_multisite() ) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
if ( $this->switched ) {
restore_current_blog();
$this->switched = false;
}
}
/**
* Get posts from Elasticsearch
*
* @param array $posts Array of posts
* @param WP_Query $query WP_Query instance
* @since 3.0
* @return string
*/
public function get_es_posts( $posts, $query ) {
global $wpdb;
/**
* Filter to skip WP Query integration
*
* @hook ep_skip_query_integration
* @param {bool} $skip True to skip
* @param {WP_Query} $query WP Query to evaluate
* @return {bool} New skip value
*/
if ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) {
return $posts;
}
$query_vars = $query->query_vars;
/**
* Filter post type query variables before WP Query
*
* @since 2.1
* @hook ep_query_post_type
* @param {string|array} $post_types Post types
* @param {WP_Query} $query WP Query object
* @return {string|array} New post types
*/
$query_vars['post_type'] = apply_filters( 'ep_query_post_type', $query_vars['post_type'], $query );
if ( 'any' === $query_vars['post_type'] ) {
unset( $query_vars['post_type'] );
}
/**
* If not search and not set default to post. If not set and is search, use searchable post types
*/
if ( empty( $query_vars['post_type'] ) ) {
if ( $query->is_tax() && $query->get_queried_object() ) {
$query_vars['post_type'] = get_taxonomy( $query->get_queried_object()->taxonomy )->object_type;
} elseif ( empty( $query_vars['s'] ) ) {
$query_vars['post_type'] = 'post';
} else {
$query_vars['post_type'] = array_values( get_post_types( array( 'exclude_from_search' => false ) ) );
}
}
/**
* No post types so bail
*/
if ( empty( $query_vars['post_type'] ) ) {
return [];
}
/**
* Filter cached posts pre-post query
*
* @hook ep_wp_query_cached_posts
* @param {array} $posts Array of posts
* @param {WP_Query} $query WP Query object
* @return {array} New cached posts
*/
$new_posts = apply_filters( 'ep_wp_query_cached_posts', [], $query );
$ep_query = null;
if ( count( $new_posts ) < 1 ) {
$scope = 'current';
$site__in = '';
$site__not_in = '';
if ( ! empty( $query_vars['sites'] ) ) {
_deprecated_argument( __FUNCTION__, '4.4.0', esc_html__( 'sites is deprecated. Use site__in instead.', 'elasticpress' ) );
}
if ( ! empty( $query_vars['site__in'] ) || ! empty( $query_vars['sites'] ) ) {
$site__in = ! empty( $query_vars['site__in'] ) ? (array) $query_vars['site__in'] : (array) $query_vars['sites'];
if ( in_array( 'all', $site__in, true ) ) {
$scope = 'all';
} elseif ( in_array( 'current', $site__in, true ) ) {
$site__in = (array) get_current_blog_id();
}
}
if ( ! empty( $query_vars['site__not_in'] ) ) {
$site__not_in = (array) $query_vars['site__not_in'];
}
$formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_vars, $query );
/**
* Filter post query scope
*
* @hook ep_search_scope
* @param {string} $scope Current scope
* @return {string} New scope
* @since 2.1
*/
$scope = apply_filters( 'ep_search_scope', $scope );
if ( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) {
// @codeCoverageIgnoreStart
$scope = 'current';
// @codeCoverageIgnoreEnd
}
$index = null;
if ( 'all' === $scope ) {
$index = Indexables::factory()->get( 'post' )->get_network_alias();
} elseif ( ! empty( $site__in ) ) {
$index = [];
foreach ( $site__in as $site_id ) {
$index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
}
$index = implode( ',', $index );
} elseif ( ! empty( $site__not_in ) ) {
$sites = \get_sites(
array(
'fields' => 'ids',
'site__not_in' => $site__not_in,
)
);
foreach ( $sites as $site_id ) {
if ( ! Utils\is_site_indexable( $site_id ) ) {
continue;
}
$index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
}
$index = implode( ',', $index );
}
$ep_query = Indexables::factory()->get( 'post' )->query_es( $formatted_args, $query->query_vars, $index, $query );
/**
* ES failed. Go back to MySQL.
*/
if ( false === $ep_query ) {
$query->elasticsearch_success = false;
return null;
}
$found_documents = is_array( $ep_query['found_documents'] ) ? $ep_query['found_documents']['value'] : $ep_query['found_documents']; // 7.0+ have this as an array rather than int
$query->found_posts = $found_documents;
$query->num_posts = $query->found_posts;
$query->max_num_pages = ceil( $found_documents / $query->get( 'posts_per_page' ) );
$query->suggested_terms = $this->maybe_sanitize_suggestion( $ep_query );
$query->elasticsearch_success = true;
// Determine how we should format the results from ES based on the fields parameter.
$fields = $query->get( 'fields', '' );
switch ( $fields ) {
case 'ids':
$new_posts = $this->format_hits_as_ids( $ep_query['documents'], $new_posts );
break;
case 'id=>parent':
$new_posts = $this->format_hits_as_id_parents( $ep_query['documents'], $new_posts );
break;
default:
$new_posts = $this->format_hits_as_posts( $ep_query['documents'], $new_posts );
break;
}
/**
* Fires after non cached post query
*
* @hook ep_wp_query_non_cached_search
* @param {array} $new_posts Array of posts from query
* @param {array} $ep_query Raw Elasticsearch query
* @param {WP_Query} $query WordPress query
*/
do_action( 'ep_wp_query_non_cached_search', $new_posts, $ep_query, $query );
}
/**
* Fires before returning posts from query
*
* @hook ep_wp_query
* @param {array} $new_posts Array of posts from query
* @param {array} $ep_query Raw Elasticsearch query
* @param {WP_Query} $query WordPress query
*/
do_action( 'ep_wp_query', $new_posts, $ep_query, $query );
/**
* Fires before returning posts from query
*
* Pre-3.0 backwards compat
*
* @hook ep_wp_query_search
* @param {array} $new_posts Array of posts from query
* @param {array} $ep_query Raw Elasticsearch query
* @param {WP_Query} $query WordPress query
*/
do_action( 'ep_wp_query_search', $new_posts, $ep_query, $query );
return $new_posts;
}
/**
* Format the ES hits/results as post objects.
*
* @since 2.4.0
*
* @param array $posts The posts that should be formatted.
* @param array $new_posts Array of posts from cache.
*
* @return array
*/
protected function format_hits_as_posts( $posts, $new_posts ) {
foreach ( $posts as $post_array ) {
$post = new \stdClass();
$post->ID = $post_array['post_id'];
$post->site_id = get_current_blog_id();
if ( ! empty( $post_array['site_id'] ) ) {
$post->site_id = $post_array['site_id'];
}
/**
* Filter post object properties set after query
*
* @hook ep_search_post_return_args
* @param {array} $properties Post properties
* @return {array} New properties
*/
$post_return_args = apply_filters(
'ep_search_post_return_args',
array(
'post_type',
'post_author',
'post_name',
'post_status',
'post_title',
'post_parent',
'post_content',
'post_excerpt',
'post_date',
'post_date_gmt',
'post_modified',
'post_modified_gmt',
'post_mime_type',
'comment_count',
'comment_status',
'ping_status',
'menu_order',
'permalink',
'terms',
'post_meta',
'meta',
)
);
foreach ( $post_return_args as $key ) {
if ( 'post_author' === $key ) {
$post->$key = $post_array[ $key ]['id'];
} elseif ( isset( $post_array[ $key ] ) ) {
$post->$key = $post_array[ $key ];
}
}
/**
* Replace post attributes with highlighted versions if available.
*
* $post_array['highlight'] is set from $hit['highlight'] in Elasticsearch.php
* when going through the returned results, and that is defined by
* the Highlighting Feature on setup, calling ep_formatted_args to
* define the highlight array of fields.
*/
if ( isset( $post_array['highlight'] ) ) {
foreach ( $post_array['highlight'] as $key => $val ) {
// e.g. $post->post_content
if ( isset( $post->$key ) ) {
/**
* e.g. replaces post content value with the highlighted value
* $post->post_content = implode( ' ', $post_array['highlight']['post_content'] );
*
* Depending on how highlight.fields.<field>.number_of_fragments is set in the query,
* Elasticsearch can return an array with N entries, with N being the number
* of matches found.
*/
$post->$key = implode( ' ', $val );
}
}
}
$post->elasticsearch = true; // Super useful for debugging
if ( $post ) {
$new_posts[] = $post;
}
}
return $new_posts;
}
/**
* Format the ES hits/results as an array of ids.
*
* @since 2.4.0
*
* @param array $posts The posts that should be formatted.
* @param array $new_posts Array of posts from cache.
*
* @return array
*/
protected function format_hits_as_ids( $posts, $new_posts ) {
foreach ( $posts as $post_array ) {
$new_posts[] = $post_array['post_id'];
}
return $new_posts;
}
/**
* Format the ES hits/results as objects containing id and parent id.
*
* @since 2.4.0
*
* @param array $posts The posts that should be formatted.
* @param array $new_posts Array of posts from cache.
*
* @return array
*/
protected function format_hits_as_id_parents( $posts, $new_posts ) {
foreach ( $posts as $post_array ) {
$post = new \stdClass();
$post->ID = $post_array['post_id'];
$post->post_parent = $post_array['post_parent'];
$post->elasticsearch = true; // Super useful for debugging
$new_posts[] = $post;
}
return $new_posts;
}
/**
* Remove any suggestion that has a score lower than the minimum score.
*
* @since 4.6.0
* @param array $ep_query The query array.
* @return array
*/
protected function maybe_sanitize_suggestion( $ep_query ) {
if ( ! isset( $ep_query['suggest']['ep_suggestion'], $ep_query['suggest']['ep_suggestion'][0] ) ) {
return [];
}
$suggestion = $ep_query['suggest']['ep_suggestion'][0];
/**
* Filter the score for a suggestion. If the score is lower than this, it will be removed.
*
* @since 4.6.0
* @param float $min_score The minimum score allowed.
* @return float
*/
$min_score = (float) apply_filters( 'ep_suggestion_minimum_score', 0.0001 );
$suggestion['options'] = array_filter(
$suggestion['options'],
function ( $option ) use ( $min_score ) {
return number_format( $option['score'], 10 ) > $min_score;
}
);
return $suggestion;
}
}