Source: includes/classes/Feature/Facets/Facets.php

<?php
/**
 * Facets feature
 *
 * @since  2.5
 * @package  elasticpress
 */

namespace ElasticPress\Feature\Facets;

use ElasticPress\Feature;
use ElasticPress\Features;
use ElasticPress\Indexables;
use ElasticPress\REST;
use ElasticPress\Utils;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Facets feature class
 */
class Facets extends Feature {
	/**
	 * Facet types (taxonomy, meta fields, etc.)
	 *
	 * @since 4.3.0
	 * @var array
	 */
	public $types = [];

	/**
	 * Initialize feature setting it's config
	 *
	 * @since  3.0
	 */
	public function __construct() {
		$this->slug = 'facets';

		$this->group = 'core-search';

		$this->requires_install_reindex = false;

		$this->default_settings = [
			'match_type' => 'all',
		];

		$types = [
			'taxonomy' => __NAMESPACE__ . '\Types\Taxonomy\FacetType',
		];

		if ( version_compare( get_bloginfo( 'version' ), '5.8', '>=' ) ) {
			$types['meta']       = __NAMESPACE__ . '\Types\Meta\FacetType';
			$types['meta-range'] = __NAMESPACE__ . '\Types\MetaRange\FacetType';
			$types['post-type']  = __NAMESPACE__ . '\Types\PostType\FacetType';
			$types['date']       = __NAMESPACE__ . '\Types\Date\FacetType';

		}

		/**
		 * Filter the Facet types available.
		 *
		 * ```
		 * add_filter(
		 *     'ep_facet_types',
		 *     function ( $types ) {
		 *         $types['post_type'] = '\MyPlugin\PostType';
		 *         return $types;
		 *     }
		 * );
		 * ```
		 *
		 * @since 4.3.0
		 * @hook ep_facet_types
		 * @param {array} $types Array of types available. Keys are slugs, values are class names.
		 * @return {array} New array of types available
		 */
		$types = apply_filters( 'ep_facet_types', $types );

		foreach ( $types as $type => $class ) {
			if ( is_a( $class, __NAMESPACE__ . '\FacetType', true ) ) {
				$this->types[ $type ] = new $class();
			}
		}

		parent::__construct();
	}

	/**
	 * Sets i18n strings.
	 *
	 * @return void
	 * @since 5.2.0
	 */
	public function set_i18n_strings(): void {
		$this->title = esc_html__( 'Filters', 'elasticpress' );

		$this->summary = '<p>' .
		( wp_is_block_theme()
			? sprintf(
				/* translators: Site Editor URL */
				__( 'Adds <a href="%s">filter blocks</a> that administrators can add to the website’s templates and template parts, so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ),
				esc_url( admin_url( 'site-editor.php' ) )
			)
			: sprintf(
				/* translators: Widgets Edit Screen URL */
				__( 'Adds <a href="%s">filter widgets</a> that administrators can add to the website’s sidebars (widgetized areas), so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ),
				esc_url( admin_url( 'widgets.php' ) )
			)
		) . '</p>';

		$this->docs_url = __( 'https://www.elasticpress.io/documentation/article/configuring-elasticpress-via-the-plugin-dashboard/#filters', 'elasticpress' );
	}

	/**
	 * Setup hooks and filters for feature
	 *
	 * @since 2.5
	 */
	public function setup() {
		foreach ( $this->types as $type => $class ) {
			$this->types[ $type ]->setup();
		}

		add_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_legacy_widget' ] );
		add_action( 'ep_valid_response', [ $this, 'get_aggs' ], 10, 4 );
		add_action( 'wp_enqueue_scripts', [ $this, 'front_scripts' ] );
		add_action( 'enqueue_block_editor_assets', [ $this, 'front_scripts' ] );
		add_action( 'ep_feature_box_settings_facets', [ $this, 'settings' ], 10, 1 );
		add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );
		add_action( 'pre_get_posts', [ $this, 'facet_query' ] );
		add_filter( 'ep_post_filters', [ $this, 'apply_facets_filters' ], 10, 3 );
		add_action( 'rest_api_init', [ $this, 'setup_endpoints' ] );
	}

	/**
	 * Unsetup Facets related hooks
	 *
	 * @since 5.1.0
	 */
	public function tear_down() {
		remove_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_legacy_widget' ] );
		remove_action( 'ep_valid_response', [ $this, 'get_aggs' ] );
		remove_action( 'wp_enqueue_scripts', [ $this, 'front_scripts' ] );
		remove_action( 'enqueue_block_editor_assets', [ $this, 'front_scripts' ] );
		remove_action( 'ep_feature_box_settings_facets', [ $this, 'settings' ] );
		remove_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ] );
		remove_action( 'pre_get_posts', [ $this, 'facet_query' ] );
		remove_filter( 'ep_post_filters', [ $this, 'apply_facets_filters' ] );
		remove_action( 'rest_api_init', [ $this, 'setup_endpoints' ] );
	}

	/**
	 * If we are doing `or` matches, we need to remove filters from aggs.
	 *
	 * By default, the same filters applied to the main query are applied to aggregations.
	 * If doing `or` matches, those should be removed so we get a broader set of results.
	 *
	 * @param  array    $args ES arguments
	 * @param  array    $query_args Query arguments
	 * @param  WP_Query $query WP Query instance
	 * @since  2.5
	 * @return array
	 */
	public function set_agg_filters( $args, $query_args, $query ) {
		// Not a facetable query
		if ( empty( $query_args['ep_facet'] ) ) {
			return $args;
		}

		if ( 'any' === $this->get_match_type() ) {
			add_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 );
		}

		/**
		 * This flag is used to differentiate filters being applied to the query and to its aggregations.
		 */
		$query_args['ep_facet_adding_agg_filters'] = true;

		/**
		 * Filter WP query arguments that will be used to build the aggregations filter.
		 *
		 * The returned `$query_args` will be used to build the aggregations filter passing
		 * it through `Indexable\Post\Post::format_args()`.
		 *
		 * @hook ep_facet_agg_filters
		 * @since 4.3.0
		 * @param {array} $query_args Query arguments
		 * @param {array} $args       ES arguments
		 * @param {array} $query      WP Query instance
		 * @return {array} New facets aggregations
		 */
		$query_args = apply_filters( 'ep_facet_agg_filters', $query_args, $args, $query );

		remove_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );
		$facet_formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_args, $query );
		add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 );

		remove_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 );

		$args['aggs']['terms']['filter'] = $facet_formatted_args['post_filter'];

		return $args;
	}

	/**
	 * Output scripts for widget admin
	 *
	 * @param  string $hook WP hook
	 * @since  2.5
	 */
	public function admin_scripts( $hook ) {
		_doing_it_wrong(
			__METHOD__,
			esc_html__( 'Facets no longer require admin styles.', 'elasticpress' ),
			'4.7.0'
		);
	}

	/**
	 * Output front end facets styles
	 *
	 * @since 2.5
	 */
	public function front_scripts() {
		wp_register_script(
			'elasticpress-facets',
			EP_URL . 'dist/js/facets-script.js',
			Utils\get_asset_info( 'facets-script', 'dependencies' ),
			Utils\get_asset_info( 'facets-script', 'version' ),
			true
		);

		wp_set_script_translations( 'elasticpress-facets', 'elasticpress' );

		wp_register_style(
			'elasticpress-facets',
			EP_URL . 'dist/css/facets-styles.css',
			Utils\get_asset_info( 'facets-styles', 'dependencies' ),
			Utils\get_asset_info( 'facets-styles', 'version' )
		);
	}

	/**
	 * Figure out if we can/should facet the query
	 *
	 * @param  WP_Query $query WP Query
	 * @since  2.5
	 * @return bool
	 */
	public function is_facetable( $query ) {

		/**
		 * Bypass the standard checks and set a query to be facetable.
		 *
		 * @deprecated 5.3.0 Use the 'ep_is_facetable' argument in WP_Query instead.
		 *
		 * @hook ep_is_facetable
		 * @param  {bool}     $bypass Defaults to false.
		 * @param  {WP_Query} $query  The current WP_Query.
		 * @return {bool}     true to bypass, false to ignore
		 */
		if ( apply_filters_deprecated(
			'ep_is_facetable',
			[ false, $query ],
			'ElasticPress 5.3.0',
			'WP_Query->ep_is_facetable argument'
		) ) {
			return true;
		}

		if ( ! empty( $query->get( 'ep_is_facetable' ) ) ) {
			return true;
		}

		if ( is_admin() || is_feed() ) {
			return false;
		}

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return false;
		}

		if ( ! $query->is_main_query() ) {
			return false;
		}

		$ep_integrate = $query->get( 'ep_integrate', null );

		if ( false === $ep_integrate ) {
			return false;
		}

		$woocommerce = Features::factory()->get_registered_feature( 'woocommerce' );

		if ( ! $woocommerce->is_active() && ( function_exists( 'is_product_category' ) && is_product_category() ) ) {
			return false;
		}

		if ( ! $this->is_facetable_page( $query ) ) {
			return false;
		}

		return true;
	}

	/**
	 * We enable ElasticPress facet on all archive/search queries as well as non-static home pages. There is no way to know
	 * when a facet widget is used before the main query is executed so we enable EP
	 * everywhere where a facet widget could be used.
	 *
	 * @param  WP_Query $query WP Query
	 * @since  2.5
	 */
	public function facet_query( $query ) {
		if ( ! $this->is_facetable( $query ) ) {
			return;
		}

		// If any filter was selected, there is no reason to prepend the list with sticky posts.
		$selected_filters = $this->get_selected();
		if ( ! empty( array_filter( $selected_filters ) ) ) {
			$query->set( 'ignore_sticky_posts', true );
		}

		/**
		 * Filter facet aggregations.
		 *
		 * This is used by facet types to add their own aggregations to the
		 * general facet.
		 *
		 * @hook ep_facet_wp_query_aggs_facet
		 * @since 4.3.0
		 * @param {array} $facets Facets aggregations
		 * @return {array} New facets aggregations
		 */
		$facets = apply_filters( 'ep_facet_wp_query_aggs_facet', [] );

		if ( empty( $facets ) ) {
			return;
		}

		$query->set( 'ep_integrate', true );
		$query->set( 'ep_facet', true );

		$aggs = array(
			'name'       => 'terms',
			'use-filter' => true,
			'aggs'       => $facets,
		);

		$query->set( 'aggs', $aggs );
	}

	/**
	 * Get aggregations from Elasticsearch response and store on query object
	 *
	 * @param  array $response ES response
	 * @param  array $query Prepared Elasticsearch query
	 * @param  array $query_args Current WP Query arguments
	 * @param  mixed $query_object Could be WP_Query, WP_User_Query, etc.
	 * @since  2.5
	 */
	public function get_aggs( $response, $query, $query_args, $query_object ) {
		if ( empty( $query_object ) || ! $query_object instanceof \WP_Query || ! $this->is_facetable( $query_object ) ) {
			return;
		}

		if ( ! empty( $response['aggregations'] ) ) {
			$processed_aggs = [];

			if ( isset( $response['aggregations']['terms'] ) && is_array( $response['aggregations']['terms'] ) ) {
				foreach ( $response['aggregations']['terms'] as $key => $agg ) {
					if ( 'doc_count' === $key ) {
						continue;
					}

					if ( ! is_array( $agg ) || ( empty( $agg['buckets'] ) && empty( $agg['value'] ) ) ) {
						continue;
					}

					$processed_aggs[ $key ] = [];

					if ( ! empty( $agg['value'] ) ) {
						$processed_aggs[ $key ] = $agg['value'];
						continue;
					}

					foreach ( $agg['buckets'] as $bucket ) {
						$processed_aggs[ $key ][ $bucket['key'] ] = $bucket['doc_count'];
					}
				}
			}

			$this->set_query_aggregations( $query_object, $processed_aggs );
		}
	}

	/**
	 * Set aggregation data for a query.
	 *
	 * @since 5.3.0
	 * @param \WP_Query $query        The WP_Query object to store data for
	 * @param array     $aggregations The aggregation data to store
	 */
	public function set_query_aggregations( $query, $aggregations ): void {
		// Store aggregations on the query object
		$query->ep_aggregations = $aggregations;

		if ( $query->is_main_query() && $this->should_sync_to_global( $query ) ) {
			_doing_it_wrong(
				__METHOD__,
				esc_html__( 'The global variable $GLOBALS[\'ep_facet_aggs\'] is deprecated. Access aggregation data directly from the query object using $query->ep_aggregations or the Facets feature methods get_query_aggregations() and get_facet_aggregation().', 'elasticpress' ),
				'ElasticPress 5.3.0'
			);

			$GLOBALS['ep_facet_aggs'] = $aggregations;
		}
	}

	/**
	 * Get aggregation data for a specific query
	 *
	 * @since 5.3.0
	 * @param \WP_Query $query The WP_Query object
	 * @return array|false Aggregation data or false if not found
	 */
	public function get_query_aggregations( $query ) {
		if ( $query instanceof \WP_Query && isset( $query->ep_aggregations ) && false !== $query->ep_aggregations ) {
			return $query->ep_aggregations;
		}
		// Fallback to global variable
		return $GLOBALS['ep_facet_aggs'] ?? false;
	}

	/**
	 * Get aggregation data for a specific facet within a query
	 *
	 * @since 5.3.0
	 * @param \WP_Query $query      The WP_Query object
	 * @param string    $facet_name The name of the facet to retrieve
	 * @return array|false Facet aggregation data or false if not found
	 */
	public function get_facet_aggregation( $query, $facet_name ) {
		$aggregations = $this->get_query_aggregations( $query );

		if ( false === $aggregations || ! isset( $aggregations[ $facet_name ] ) ) {
			return false;
		}

		return $aggregations[ $facet_name ];
	}

	/**
	 * Determine if aggregations should be synced to global variable
	 *
	 * @since 5.3.0
	 * @param \WP_Query $query The query to evaluate
	 * @return bool True if should sync to global, false otherwise
	 */
	protected function should_sync_to_global( $query ) {
		/**
		 * Filter whether to sync query aggregations to global variable
		 *
		 * @since 5.3.0
		 * @hook ep_facet_sync_aggregations_to_global
		 * @param {bool}     $sync  Whether to sync (default: false)
		 * @param {WP_Query} $query The query object
		 * @return {bool} Whether to sync aggregations to global variable
		 */
		return apply_filters( 'ep_facet_sync_aggregations_to_global', false, $query );
	}

	/**
	 * Get currently selected facets from query args
	 *
	 * @since  2.5
	 * @return array
	 */
	public function get_selected() {
		$allowed_args = $this->get_allowed_query_args();

		$filters      = [];
		$filter_names = [];
		foreach ( $this->types as $type_obj ) {
			$filter_names[ $type_obj->get_filter_name() ] = $type_obj;
		}

		foreach ( $_GET as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification
			$key = sanitize_key( $key );

			foreach ( $filter_names as $filter_name => $type_obj ) {
				if ( 0 === strpos( $key, $filter_name ) ) {
					if ( empty( $value ) ) {
						continue;
					}
					$facet = str_replace( $filter_name, '', $key );

					$filters = $type_obj->format_selected( $facet, $value, $filters );
				}
			}

			if ( in_array( $key, $allowed_args, true ) ) {
				$filters[ $key ] = $value;
			}
		}

		/**
		 * Filter selected filters.
		 *
		 * @hook ep_facet_selected_filters
		 * @since 5.1.4
		 * @param  {array} $filters Current filters
		 * @return {array} New filters
		 */
		return apply_filters( 'ep_facet_selected_filters', $filters );
	}

	/**
	 * Build query url
	 *
	 * @since  2.5
	 * @param  array $filters Facet filters
	 * @return string
	 */
	public function build_query_url( $filters ) {
		$query_params = array();

		foreach ( $this->types as $type_obj ) {
			if ( empty( $filters[ $type_obj->get_filter_type() ] ) ) {
				continue;
			}
			$query_params = $type_obj->add_query_params( $query_params, $filters );
		}

		$feature      = Features::factory()->get_registered_feature( 'facets' );
		$allowed_args = $feature->get_allowed_query_args();

		if ( ! empty( $filters ) ) {
			foreach ( $filters as $filter => $value ) {
				if ( in_array( $filter, $allowed_args, true ) ) {
					$query_params[ $filter ] = $value;
				}
			}
		}

		$query_string = build_query( $query_params );

		/**
		 * Filter facet query string
		 *
		 * @hook ep_facet_query_string
		 * @param  {string} $query_string Current query string
		 * @param  {array}  $query_params Query parameters
		 * @return  {string} New query string
		 */
		$query_string = apply_filters( 'ep_facet_query_string', $query_string, $query_params );

		$url        = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$pagination = strpos( $url, '/page' );
		if ( false !== $pagination ) {
			$url = substr( $url, 0, $pagination );
		}

		return strtok( trailingslashit( $url ), '?' ) . ( ( ! empty( $query_string ) ) ? '?' . $query_string : '' );
	}

	/**
	 * Register facet widget(s)
	 *
	 * @since 2.5, deprecated in 4.3.0
	 */
	public function register_widgets() {
		_deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types[ \$type ]->register_widgets()" );
	}

	/**
	 * Hide the legacy widget.
	 *
	 * Hides the legacy widget in favor of the Block when the block editor
	 * is in use and the legacy widget has not been used.
	 *
	 * @since 4.3
	 * @param array $widgets An array of excluded widget-type IDs.
	 * @return array array of excluded widget-type IDs to hide.
	 */
	public function hide_legacy_widget( $widgets ) {
		$widgets[] = 'ep-facet';
		$widgets[] = 'ep-facet-date';
		$widgets[] = 'ep-facet-meta';
		$widgets[] = 'ep-facet-meta-range';

		return $widgets;
	}

	/**
	 * Returns allowed query args for facets
	 *
	 * @return mixed|void
	 * @since 3.6.0
	 */
	public function get_allowed_query_args() {
		$args = array( 's', 'post_type', 'orderby' );

		// Retrieve all registered query variables for public taxonomies
		$taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );
		foreach ( $taxonomies as $taxonomy ) {
			if ( $taxonomy->query_var ) {
				$args[] = $taxonomy->query_var;
			}
		}
		/**
		 * To keep backward compatibility, WordPress uses  `'cat'` for default categories.
		 * It also allows access using the `?taxonomy=<tax>&term=<term>` format.
		 *
		 * @see get_term_link()
		 */
		$args = array_merge( $args, [ 'cat', 'taxonomy', 'term' ] );

		/**
		 * Filter allowed query args
		 *
		 * @hook    ep_facet_allowed_query_args
		 * @since 3.6.0
		 * @param   {array} $args Post types
		 * @return  {array} New post types
		 */
		return apply_filters( 'ep_facet_allowed_query_args', $args );
	}

	/**
	 * Get the facet filter name.
	 *
	 * @return string The filter name.
	 */
	protected function get_filter_name() {
		_deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_filter_name()" );

		return $this->types['taxonomy']->get_filter_name();
	}

	/**
	 * Get all taxonomies that could be selected for a facet.
	 *
	 * @since 4.2.0, deprecated in 4.3.0
	 * @return array
	 */
	public function get_facetable_taxonomies() {
		_deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types['taxonomy']->get_facetable_taxonomies()" );

		return $this->types['taxonomy']->get_filter_name();
	}

	/**
	 * Add a new filter to the ES query with selected facets
	 *
	 * @since 4.4.0
	 * @param array    $filters  Current filters
	 * @param array    $args     WP Query args
	 * @param WP_Query $query    WP Query object
	 * @return array
	 */
	public function apply_facets_filters( $filters, $args, $query ) {
		if ( ! $this->is_facetable( $query ) ) {
			return $filters;
		}

		/**
		 * Filter facet selection filters to be applied to the ES query
		 *
		 * @hook  ep_facet_query_filters
		 * @since 4.4.0
		 * @param  {array}    $filters Current filters
		 * @param  {array}    $args    WP Query args
		 * @param  {WP_Query} $query   WP Query object
		 * @return {array} New filters
		 */
		$facets_filters = apply_filters( 'ep_facet_query_filters', [], $args, $query );

		if ( empty( $facets_filters ) ) {
			return $filters;
		}

		$es_operator = ( 'any' === $this->get_match_type() ) ? 'should' : 'must';

		$filters['facets'] = [
			'bool' => [
				$es_operator => $facets_filters,
			],
		];

		return $filters;
	}

	/**
	 * Utilitary function to retrieve the match type selected by the user.
	 *
	 * @since 4.4.0
	 * @return string
	 */
	public function get_match_type() {
		$settings = $this->get_settings();

		/**
		 * Filter the match type of all facets. Can be 'all' or 'any'.
		 *
		 * @hook  ep_facet_match_type
		 * @since 4.4.0
		 * @param  {string} $match_type Current selection
		 * @return {string} New selection
		 */
		return apply_filters( 'ep_facet_match_type', $settings['match_type'] );
	}

	/**
	 * Given an array of filters, remove the facets filter.
	 *
	 * This is used when the user wants posts matching ANY criteria, so aggregations should not restrict their results.
	 *
	 * @since 4.4.0
	 * @param array $filters Filters to be applied to the ES query
	 * @return array
	 */
	public function remove_facets_filter( $filters ) {
		unset( $filters['facets'] );
		return $filters;
	}

	/**
	 * Set the `settings_schema` attribute
	 *
	 * @since 5.0.0
	 */
	protected function set_settings_schema() {
		$this->settings_schema[] = [
			'key'     => 'match_type',
			'label'   => __( 'Filter matching', 'elasticpress' ),
			'options' => [
				[
					'label' => __( 'Show results that match <strong>all</strong> selected filters', 'elasticpress' ),
					'value' => 'all',
				],
				[
					'label' => __( 'Show results that match <strong>any</strong> selected filter', 'elasticpress' ),
					'value' => 'any',
				],
			],
			'type'    => 'radio',
		];
	}

	/**
	 * Figure out if Facet widget can display on page.
	 *
	 * @param  WP_Query $query WP Query
	 * @since  4.2.1
	 * @return bool
	 */
	protected function is_facetable_page( $query ) {
		return $query->is_home() || $query->is_search() || $query->is_tax() || $query->is_tag() || $query->is_category() || $query->is_post_type_archive();
	}

	/**
	 * Setup REST endpoints
	 *
	 * @since 5.0.0
	 */
	public function setup_endpoints() {
		$meta_keys = new REST\MetaKeys();
		$meta_keys->register_routes();

		$meta_range = new REST\MetaRange();
		$meta_range->register_routes();

		$taxonomies = new REST\Taxonomies();
		$taxonomies->register_routes();
	}
}