); } /** * Return a query that filters products by price. * * @return array */ private function get_filter_by_price_query() { $min_price = get_query_var( PriceFilter::MIN_PRICE_QUERY_VAR ); $max_price = get_query_var( PriceFilter::MAX_PRICE_QUERY_VAR ); $max_price_query = empty( $max_price ) ? array() : array( 'key' => '_price', 'value' => $max_price, 'compare' => '<', 'type' => 'numeric', ); $min_price_query = empty( $min_price ) ? array() : array( 'key' => '_price', 'value' => $min_price, 'compare' => '>=', 'type' => 'numeric', ); if ( empty( $min_price_query ) && empty( $max_price_query ) ) { return array(); } return array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'relation' => 'AND', $max_price_query, $min_price_query, ), ), ); } /** * Return a query that filters products by attributes. * * @return array */ private function get_filter_by_attributes_query() { $attributes_filter_query_args = $this->get_filter_by_attributes_query_vars(); $queries = array_reduce( $attributes_filter_query_args, function ( $acc, $query_args ) { $attribute_name = $query_args['filter']; $attribute_query_type = $query_args['query_type']; $attribute_value = get_query_var( $attribute_name ); $attribute_query = get_query_var( $attribute_query_type ); if ( empty( $attribute_value ) ) { return $acc; } // It is necessary explode the value because $attribute_value can be a string with multiple values (e.g. "red,blue"). $attribute_value = explode( ',', $attribute_value ); $acc[] = array( 'taxonomy' => str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name ), 'field' => 'slug', 'terms' => $attribute_value, 'operator' => 'and' === $attribute_query ? 'AND' : 'IN', ); return $acc; }, array() ); if ( empty( $queries ) ) { return array(); } return array( // phpcs:ignore WordPress.DB.SlowDBQuery 'tax_query' => array( array( 'relation' => 'AND', $queries, ), ), ); } /** * Get all the query args related to the filter by attributes block. * * @return array * [color] => Array * ( * [filter] => filter_color * [query_type] => query_type_color * ) * * [size] => Array * ( * [filter] => filter_size * [query_type] => query_type_size * ) * ) */ private function get_filter_by_attributes_query_vars() { if ( ! empty( $this->attributes_filter_query_args ) ) { return $this->attributes_filter_query_args; } $this->attributes_filter_query_args = array_reduce( wc_get_attribute_taxonomies(), function ( $acc, $attribute ) { $acc[ $attribute->attribute_name ] = array( 'filter' => AttributeFilter::FILTER_QUERY_VAR_PREFIX . $attribute->attribute_name, 'query_type' => AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute->attribute_name, ); return $acc; }, array() ); return $this->attributes_filter_query_args; } /** * Return a query that filters products by stock status. * * @return array */ private function get_filter_by_stock_status_query() { $filter_stock_status_values = get_query_var( StockFilter::STOCK_STATUS_QUERY_VAR ); if ( empty( $filter_stock_status_values ) ) { return array(); } $filtered_stock_status_values = array_filter( explode( ',', $filter_stock_status_values ), function ( $stock_status ) { return in_array( $stock_status, StockFilter::get_stock_status_query_var_values(), true ); } ); if ( empty( $filtered_stock_status_values ) ) { return array(); } return array( // Ignoring the warning of not using meta queries. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_stock_status', 'value' => $filtered_stock_status_values, 'operator' => 'IN', ), ), ); } /** * Return a query that filters products by rating. * * @return array */ private function get_filter_by_rating_query() { $filter_rating_values = get_query_var( RatingFilter::RATING_QUERY_VAR ); if ( empty( $filter_rating_values ) ) { return array(); } $parsed_filter_rating_values = explode( ',', $filter_rating_values ); $product_visibility_terms = wc_get_product_visibility_term_ids(); if ( empty( $parsed_filter_rating_values ) || empty( $product_visibility_terms ) ) { return array(); } $rating_terms = array_map( function ( $rating ) use ( $product_visibility_terms ) { return $product_visibility_terms[ 'rated-' . $rating ]; }, $parsed_filter_rating_values ); return array( // phpcs:ignore WordPress.DB.SlowDBQuery 'tax_query' => array( array( 'field' => 'term_taxonomy_id', 'taxonomy' => 'product_visibility', 'terms' => $rating_terms, 'operator' => 'IN', 'rating_filter' => true, ), ), ); } /** * Constructs a date query for product filtering based on a specified time frame. * * @param array $time_frame { * Associative array with 'operator' (in or not-in) and 'value' (date string). * * @type string $operator Determines the inclusion or exclusion of the date range. * @type string $value The date around which the range is applied. * } * @return array Date query array; empty if parameters are invalid. */ private function get_date_query( array $time_frame ): array { // Validate time_frame elements. if ( empty( $time_frame['operator'] ) || empty( $time_frame['value'] ) ) { return array(); } // Determine the query operator based on the 'operator' value. $query_operator = 'in' === $time_frame['operator'] ? 'after' : 'before'; // Construct and return the date query. return array( 'date_query' => array( array( 'column' => 'post_date_gmt', $query_operator => $time_frame['value'], 'inclusive' => true, ), ), ); } /** * Get query arguments for price range filter. * We are adding these extra query arguments to be used in `posts_clauses` * because there are 2 special edge cases we wanna handle for Price range filter: * Case 1: Prices excluding tax are displayed including tax * Case 2: Prices including tax are displayed excluding tax * * Both of these cases require us to modify SQL query to get the correct results. * * See add_price_range_filter_posts_clauses function in this file for more details. * * @param array $price_range Price range with min and max values. * @return array Query arguments. */ public function get_price_range_query_args( $price_range ) { if ( empty( $price_range ) ) { return array(); } return array( 'isProductCollection' => true, 'priceRange' => $price_range, ); } /** * Add the `posts_clauses` filter to the main query. * * @param array $clauses The query clauses. * @param WP_Query $query The WP_Query instance. */ public function add_price_range_filter_posts_clauses( $clauses, $query ) { $query_vars = $query->query_vars; $is_product_collection_block = $query_vars['isProductCollection'] ?? false; if ( ! $is_product_collection_block ) { return $clauses; } $price_range = $query_vars['priceRange'] ?? null; if ( empty( $price_range ) ) { return $clauses; } global $wpdb; $adjust_for_taxes = $this->should_adjust_price_range_for_taxes(); $clauses['join'] = $this->append_product_sorting_table_join( $clauses['join'] ); $min_price = $price_range['min'] ?? null; if ( $min_price ) { if ( $adjust_for_taxes ) { $clauses['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price, 'min_price', '>=' ); } else { $clauses['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', $min_price ); } } $max_price = $price_range['max'] ?? null; if ( $max_price ) { if ( $adjust_for_taxes ) { $clauses['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price, 'max_price', '<=' ); } else { $clauses['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', $max_price ); } } return $clauses; } /** * Determines if price filters need adjustment based on the tax display settings. * * This function checks if there's a discrepancy between how prices are stored in the database * and how they are displayed to the user, specifically with respect to tax inclusion or exclusion. * It returns true if an adjustment is needed, indicating that the price filters should account for this * discrepancy to display accurate prices. * * @return bool True if the price filters need to be adjusted for tax display settings, false otherwise. */ private function should_adjust_price_range_for_taxes() { $display_setting = get_option( 'woocommerce_tax_display_shop' ); // Tax display setting ('incl' or 'excl'). $price_storage_method = wc_prices_include_tax() ? 'incl' : 'excl'; return $display_setting !== $price_storage_method; } /** * Join wc_product_meta_lookup to posts if not already joined. * * @param string $sql SQL join. * @return string */ protected function append_product_sorting_table_join( $sql ) { global $wpdb; if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $sql; } /** * Get query for price filters when dealing with displayed taxes. * * @param float $price_filter Price filter to apply. * @param string $column Price being filtered (min or max). * @param string $operator Comparison operator for column. * @return string Constructed query. */ protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) { global $wpdb; // Select only used tax classes to avoid unwanted calculations. $product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" ); if ( empty( $product_tax_classes ) ) { return ''; } $or_queries = array(); // We need to adjust the filter for each possible tax class and combine the queries into one. foreach ( $product_tax_classes as $tax_class ) { $adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class ); $or_queries[] = $wpdb->prepare( '( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )', $tax_class, $adjusted_price_filter ); } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared return $wpdb->prepare( ' AND ( wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ') OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f ) ) ', $price_filter ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared } /** * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes. * * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core. * * @param float $price_filter Price filter amount as entered. * @param string $tax_class Tax class for adjustment. * @return float */ protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) { $tax_display = get_option( 'woocommerce_tax_display_shop' ); $tax_rates = WC_Tax::get_rates( $tax_class ); $base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class ); // If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax. if ( 'incl' === $tax_display ) { /** * Filters if taxes should be removed from locations outside the store base location. * * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10 * regardless of location and taxes. * * @since 2.6.0 * * @internal Matches filter name in WooCommerce core. * * @param boolean $adjust_non_base_location_prices True by default. * @return boolean */ $taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true ); return $price_filter - array_sum( $taxes ); } // If prices are shown excl. tax, add taxes to match the prices stored in the DB. $taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false ); return $price_filter + array_sum( $taxes ); } }