Automattic\WooCommerce\Internal\Admin\Reports

ProductSearchQuery{} │ WC 11.2.0

Builds the SQL that resolves a free-text product search to product IDs, and the condition restricting a products report to the products its filters resolve to.

Хуков нет.

Использование

$ProductSearchQuery = new ProductSearchQuery();
// use class methods

Методы

  1. public static add_wp_query_filter( $where, $wp_query )
  2. public static add_wp_query_join( $join, $wp_query )
  3. public static get_collection_param()
  4. public static get_id_condition( string $column, string $search_subquery, array $included_products )
  5. public static get_ids( $terms, $restrict_to_ids = array() )
  6. public static get_ids_subquery( $terms, $restrict_to_ids = array() )
  7. public static parse_terms( $value )
  8. public static skip_wp_query_results( $posts, $wp_query )
  9. private static build_statement( array $args )

Список изменений

С версии 11.2.0 Введена.

Код ProductSearchQuery{} WC 11.2.2

class ProductSearchQuery {

	/**
	 * Query variable carrying the search terms into the WP_Query filters below.
	 *
	 * @var string
	 */
	private const TERMS_QUERY_VAR = 'wc_analytics_product_search';

	/**
	 * Alias of the product meta lookup table joined for the SKU comparison.
	 *
	 * @var string
	 */
	private const LOOKUP_ALIAS = 'wc_analytics_product_search_lookup';

	/**
	 * Normalizes the `search` REST argument into a list of terms.
	 *
	 * @since 11.2.0
	 *
	 * @param string|string[] $value Raw argument value, a list of terms or a comma separated string.
	 * @return string[] Search terms.
	 */
	public static function parse_terms( $value ) {
		// Not `wp_parse_list()`, which also splits on whitespace and would break multi-word terms.
		$terms = is_array( $value ) ? $value : explode( ',', (string) $value );

		return array_values(
			array_filter(
				array_map( 'sanitize_text_field', $terms ),
				function ( $term ) {
					return '' !== $term;
				}
			)
		);
	}

	/**
	 * Returns the REST collection parameter definition for the product search.
	 *
	 * @since 11.2.0
	 *
	 * @return array Parameter definition.
	 */
	public static function get_collection_param() {
		return array(
			'description'       => __( 'Limit result to products whose name or SKU matches any of the given terms.', 'woocommerce' ),
			'type'              => 'array',
			'sanitize_callback' => array( self::class, 'parse_terms' ),
			'validate_callback' => 'rest_validate_request_arg',
			'items'             => array(
				'type' => 'string',
			),
		);
	}

	/**
	 * Returns a SELECT statement resolving the given search terms to product IDs.
	 *
	 * The statement yields a single `product_id` column, for use as a derived table or in an `IN (...)` clause.
	 *
	 * @since 11.2.0
	 *
	 * @param string|string[] $terms           Search terms. A product matches if it matches any term.
	 * @param int[]           $restrict_to_ids Optional. Product IDs to intersect the results with. An ID
	 *                                         that cannot belong to a product matches nothing.
	 * @return string SQL statement, or an empty string when there is nothing to search for.
	 */
	public static function get_ids_subquery( $terms, $restrict_to_ids = array() ) {
		$terms = self::parse_terms( $terms );

		if ( empty( $terms ) ) {
			return '';
		}

		$args = array(
			'post_type'           => 'product',
			// Matches the search box, and a drafted product can still have sales to report.
			'post_status'         => 'any',
			'posts_per_page'      => -1,
			'fields'              => 'ids',
			// The report orders and pages the result itself, so the subquery does not have to.
			'orderby'             => 'none',
			'no_found_rows'       => true,
			// The query is never run, so its empty result is not worth caching.
			'cache_results'       => false,
			self::TERMS_QUERY_VAR => $terms,
		);

		$restrict_to_ids = (array) $restrict_to_ids;
		if ( ! empty( $restrict_to_ids ) ) {
			// WP_Query runs `post__in` through `absint()`, which would read the `-1` the report
			// filters use for an empty set as product ID 1. Clamp to 0, which matches nothing.
			$args['post__in'] = array_map( static fn( $id ) => max( 0, (int) $id ), $restrict_to_ids );
		}

		$statement = self::build_statement( $args );

		// A plugin filtering `posts_fields` can add columns, so name the one this returns.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP_Query prepares the statement it builds.
		return "SELECT DISTINCT ID AS product_id FROM ( {$statement} ) AS wc_analytics_product_search_results";
	}

	/**
	 * Returns the condition restricting a report to a set of products.
	 *
	 * A search resolves to a subquery, the `categories` and `products` filters to an ID list. The
	 * subquery already covers those filters, since it is built restricted to the same IDs.
	 *
	 * Not a data store method: both products data stores need it and one extends the other, so it
	 * would become part of what extensions inherit.
	 *
	 * @since 11.2.0
	 *
	 * @param string $column            Product ID column to compare, qualified with its table name.
	 * @param string $search_subquery   Statement the `search` argument resolves to, from
	 *                                  `get_ids_subquery()`. Empty when the report carries no search.
	 * @param array  $included_products Product IDs the `categories` and `products` filters resolve to.
	 * @return string SQL condition, or an empty string when the report is not restricted.
	 */
	public static function get_id_condition( string $column, string $search_subquery, array $included_products ): string {
		if ( '' !== $search_subquery ) {
			return "{$column} IN ( {$search_subquery} )";
		}

		$id_list = implode( ',', $included_products );

		return $id_list ? "{$column} IN ( {$id_list} )" : '';
	}

	/**
	 * Returns the product IDs the given search terms resolve to.
	 *
	 * For callers that need the matches themselves rather than a statement to compose with.
	 * An empty list means the terms matched nothing, which is not the same as `null`, meaning
	 * there was nothing to search for.
	 *
	 * @since 11.2.0
	 *
	 * @param string|string[] $terms           Search terms. A product matches if it matches any term.
	 * @param int[]           $restrict_to_ids Optional. Product IDs to intersect the results with.
	 * @return int[]|null Matching product IDs, or null when there is nothing to search for.
	 */
	public static function get_ids( $terms, $restrict_to_ids = array() ) {
		global $wpdb;

		$subquery = self::get_ids_subquery( $terms, $restrict_to_ids );
		if ( '' === $subquery ) {
			return null;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $subquery is built from prepared fragments; the containing report is what caches.
		return array_map( 'intval', $wpdb->get_col( $subquery ) );
	}

	/**
	 * Adds the SKU lookup table to a product search query.
	 *
	 * @internal Hooked on `posts_join` for the duration of the search query.
	 *
	 * @since 11.2.0
	 *
	 * @param string   $join     Join clause.
	 * @param WP_Query $wp_query Query being built.
	 * @return string Join clause.
	 */
	public static function add_wp_query_join( $join, $wp_query ) {
		global $wpdb;

		if ( ! $wp_query->get( self::TERMS_QUERY_VAR ) || ! wc_product_sku_enabled() ) {
			return $join;
		}

		$alias = self::LOOKUP_ALIAS;

		return $join . " LEFT JOIN {$wpdb->wc_product_meta_lookup} AS {$alias} ON {$wpdb->posts}.ID = {$alias}.product_id ";
	}

	/**
	 * Restricts a product search query to the products matching any of its terms.
	 *
	 * @internal Hooked on `posts_where` for the duration of the search query.
	 *
	 * @since 11.2.0
	 *
	 * @param string   $where    Where clause.
	 * @param WP_Query $wp_query Query being built.
	 * @return string Where clause.
	 */
	public static function add_wp_query_filter( $where, $wp_query ) {
		global $wpdb;

		$terms = $wp_query->get( self::TERMS_QUERY_VAR );
		if ( ! $terms ) {
			return $where;
		}

		$sku_enabled  = wc_product_sku_enabled();
		$alias        = self::LOOKUP_ALIAS;
		$term_clauses = array();

		foreach ( (array) $terms as $term ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts is a table name.
			$clause = $wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", '%' . $wpdb->esc_like( $term ) . '%' );
			if ( $sku_enabled ) {
				// Matches Admin\API\Products, which leaves the term unescaped, so a LIKE wildcard stays
				// one. Escaping it would make the report disagree with the search box on what matches.
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $alias is a class constant.
				$clause .= $wpdb->prepare( " OR {$alias}.sku LIKE %s", $term );
			}

			$term_clauses[] = "( {$clause} )";
		}

		return $where . ' AND ( ' . implode( ' OR ', $term_clauses ) . ' )';
	}

	/**
	 * Skips running a product search query, which is built for its statement rather than its rows.
	 *
	 * Only the search query itself is skipped. A query another plugin runs from one of the filters
	 * above, to work out how to filter this one, still has to return its own results.
	 *
	 * @internal Hooked on `posts_pre_query` for the duration of the search query.
	 *
	 * @since 11.2.0
	 *
	 * @param array|null $posts    Posts to return instead of running the query, or null to run it.
	 * @param WP_Query   $wp_query Query being built.
	 * @return array|null Posts to return instead of running the query, or null to run it.
	 */
	public static function skip_wp_query_results( $posts, $wp_query ) {
		return $wp_query->get( self::TERMS_QUERY_VAR ) ? array() : $posts;
	}

	/**
	 * Returns the statement WP_Query builds for the given arguments, without running it.
	 *
	 * The search composes into the report query, so the statement is what is needed rather than the
	 * rows. Going through WP_Query keeps the query filters in play, which is how multilingual plugins
	 * restrict products to the active language and how the search box itself resolves a term.
	 *
	 * @param array $args Query arguments.
	 * @return string SQL statement.
	 */
	private static function build_statement( array $args ): string {
		add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
		add_filter( 'posts_pre_query', array( __CLASS__, 'skip_wp_query_results' ), 10, 2 );

		try {
			$query = new WP_Query();
			$query->query( $args );

			return $query->request;
		} finally {
			// A filter left behind would follow every later query in the request, so drop them
			// even when the query above threw.
			remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
			remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
			remove_filter( 'posts_pre_query', array( __CLASS__, 'skip_wp_query_results' ), 10 );
		}
	}
}