AgenticCheckoutUtils.php
4 months ago
ArrayUtils.php
2 years ago
CartController.php
4 weeks ago
CartTokenUtils.php
1 year ago
CheckoutTrait.php
4 weeks ago
DraftOrderTrait.php
1 year ago
JsonWebToken.php
11 months ago
LocalPickupUtils.php
5 months ago
NoticeHandler.php
1 year ago
OrderAuthorizationTrait.php
6 months ago
OrderController.php
4 weeks ago
Pagination.php
2 years ago
PaymentUtils.php
1 year ago
ProductItemTrait.php
4 weeks ago
ProductLinksTrait.php
3 months ago
ProductQuery.php
2 months ago
ProductQueryFilters.php
11 months ago
QuantityLimits.php
11 months ago
RateLimits.php
1 year ago
SanitizationUtils.php
2 years ago
ValidationUtils.php
2 years ago
ProductQuery.php
616 lines
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\StoreApi\Utilities; |
| 5 | |
| 6 | use Automattic\WooCommerce\Enums\ProductStatus; |
| 7 | use Automattic\WooCommerce\Enums\ProductType; |
| 8 | use Automattic\WooCommerce\Enums\CatalogVisibility; |
| 9 | use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator; |
| 10 | use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; |
| 11 | use WC_Tax; |
| 12 | |
| 13 | /** |
| 14 | * Product Query class. |
| 15 | * |
| 16 | * Helper class to handle product queries for the API. |
| 17 | */ |
| 18 | class ProductQuery implements QueryClausesGenerator { |
| 19 | /** |
| 20 | * Prepare query args to pass to WP_Query for a REST API request. |
| 21 | * |
| 22 | * @param \WP_REST_Request $request Request data. |
| 23 | * @return array |
| 24 | * @throws RouteException If the related product ID is invalid or the product is not visible. |
| 25 | */ |
| 26 | public function prepare_objects_query( $request ) { |
| 27 | $args = array( |
| 28 | 'offset' => $request['offset'], |
| 29 | 'order' => $request['order'], |
| 30 | 'orderby' => $request['orderby'], |
| 31 | 'paged' => $request['page'], |
| 32 | 'post__in' => $request['include'], |
| 33 | 'post__not_in' => $request['exclude'], |
| 34 | 'posts_per_page' => $request['per_page'] ? $request['per_page'] : -1, |
| 35 | 'post_parent__in' => $request['parent'], |
| 36 | 'post_parent__not_in' => $request['parent_exclude'], |
| 37 | 'search' => $request['search'], // This uses search rather than s intentionally to handle searches internally. |
| 38 | 'slug' => $request['slug'], |
| 39 | 'fields' => 'ids', |
| 40 | 'ignore_sticky_posts' => true, |
| 41 | 'post_status' => ProductStatus::PUBLISH, |
| 42 | 'date_query' => array(), |
| 43 | 'post_type' => 'product', |
| 44 | ); |
| 45 | |
| 46 | // If searching for a specific SKU or slug, allow any post type. |
| 47 | if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) { |
| 48 | $args['post_type'] = array( 'product', 'product_variation' ); |
| 49 | } |
| 50 | |
| 51 | // Taxonomy query to filter products by type, category, tag, shipping class, and attribute. |
| 52 | $tax_query = array(); |
| 53 | |
| 54 | // Filter product type by slug. |
| 55 | if ( ! empty( $request['type'] ) ) { |
| 56 | if ( ProductType::VARIATION === $request['type'] ) { |
| 57 | $args['post_type'] = 'product_variation'; |
| 58 | } else { |
| 59 | $args['post_type'] = 'product'; |
| 60 | $tax_query[] = array( |
| 61 | 'taxonomy' => 'product_type', |
| 62 | 'field' => 'slug', |
| 63 | 'terms' => $request['type'], |
| 64 | ); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | if ( 'date' === $args['orderby'] ) { |
| 69 | $args['orderby'] = 'date ID'; |
| 70 | } |
| 71 | |
| 72 | // Set before into date query. Date query must be specified as an array of an array. |
| 73 | if ( isset( $request['before'] ) ) { |
| 74 | $args['date_query'][0]['before'] = $request['before']; |
| 75 | } |
| 76 | |
| 77 | // Set after into date query. Date query must be specified as an array of an array. |
| 78 | if ( isset( $request['after'] ) ) { |
| 79 | $args['date_query'][0]['after'] = $request['after']; |
| 80 | } |
| 81 | |
| 82 | // Set date query column. Defaults to post_date. |
| 83 | if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) { |
| 84 | $args['date_query'][0]['column'] = 'post_' . $request['date_column']; |
| 85 | } |
| 86 | |
| 87 | // Set custom args to handle later during clauses. |
| 88 | $custom_keys = array( |
| 89 | 'sku', |
| 90 | 'min_price', |
| 91 | 'max_price', |
| 92 | 'stock_status', |
| 93 | ); |
| 94 | |
| 95 | foreach ( $custom_keys as $key ) { |
| 96 | if ( ! empty( $request[ $key ] ) ) { |
| 97 | $args[ $key ] = $request[ $key ]; |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | $operator_mapping = array( |
| 102 | 'in' => 'IN', |
| 103 | 'not_in' => 'NOT IN', |
| 104 | 'and' => 'AND', |
| 105 | ); |
| 106 | |
| 107 | // Gets all registered product taxonomies and prefixes them with `tax_`. |
| 108 | // This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field. |
| 109 | // eg an `sku` taxonomy will be mapped to `tax_sku`. |
| 110 | $all_product_taxonomies = array_map( |
| 111 | function ( $value ) { |
| 112 | return '_unstable_tax_' . $value; |
| 113 | }, |
| 114 | get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' ) |
| 115 | ); |
| 116 | |
| 117 | // Map between taxonomy name and arg key. |
| 118 | $default_taxonomies = array( |
| 119 | 'product_cat' => 'category', |
| 120 | 'product_tag' => 'tag', |
| 121 | 'product_brand' => 'brand', |
| 122 | ); |
| 123 | |
| 124 | $taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies ); |
| 125 | |
| 126 | // Set tax_query for each passed arg. |
| 127 | foreach ( $taxonomies as $taxonomy => $key ) { |
| 128 | if ( ! empty( $request[ $key ] ) ) { |
| 129 | $type = is_numeric( $request[ $key ][0] ) ? 'term_id' : 'slug'; |
| 130 | $operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN'; |
| 131 | $tax_query[] = array( |
| 132 | 'taxonomy' => $taxonomy, |
| 133 | 'field' => $type, |
| 134 | 'terms' => $request[ $key ], |
| 135 | 'operator' => $operator, |
| 136 | ); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Filter by attributes. |
| 141 | if ( ! empty( $request['attributes'] ) ) { |
| 142 | $att_queries = array(); |
| 143 | |
| 144 | foreach ( $request['attributes'] as $attribute ) { |
| 145 | if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) { |
| 146 | continue; |
| 147 | } |
| 148 | if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) { |
| 149 | $operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN'; |
| 150 | $att_queries[] = array( |
| 151 | 'taxonomy' => $attribute['attribute'], |
| 152 | 'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug', |
| 153 | 'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'], |
| 154 | 'operator' => $operator, |
| 155 | ); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | if ( 1 < count( $att_queries ) ) { |
| 160 | // Add relation arg when using multiple attributes. |
| 161 | $relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN'; |
| 162 | $tax_query[] = array( |
| 163 | 'relation' => $relation, |
| 164 | $att_queries, |
| 165 | ); |
| 166 | } else { |
| 167 | $tax_query = array_merge( $tax_query, $att_queries ); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Build tax_query if taxonomies are set. |
| 172 | if ( ! empty( $tax_query ) && 'product_variation' !== $args['post_type'] ) { |
| 173 | if ( ! empty( $args['tax_query'] ) ) { |
| 174 | $args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore |
| 175 | } else { |
| 176 | $args['tax_query'] = $tax_query; // phpcs:ignore |
| 177 | } |
| 178 | } else { |
| 179 | // For product_variantions we need to convert the tax_query to a meta_query. |
| 180 | if ( ! empty( $args['tax_query'] ) ) { |
| 181 | $args['meta_query'] = $this->convert_tax_query_to_meta_query( array_merge( $tax_query, $args['tax_query'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 182 | } else { |
| 183 | $args['meta_query'] = $this->convert_tax_query_to_meta_query( $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // Filter featured. |
| 188 | if ( is_bool( $request['featured'] ) ) { |
| 189 | $args['tax_query'][] = array( |
| 190 | 'taxonomy' => 'product_visibility', |
| 191 | 'field' => 'name', |
| 192 | 'terms' => 'featured', |
| 193 | 'operator' => true === $request['featured'] ? 'IN' : 'NOT IN', |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | // Filter by on sale products. |
| 198 | if ( is_bool( $request['on_sale'] ) ) { |
| 199 | $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in'; |
| 200 | $on_sale_ids = wc_get_product_ids_on_sale(); |
| 201 | |
| 202 | // Use 0 when there's no on sale products to avoid return all products. |
| 203 | $on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids; |
| 204 | |
| 205 | $args[ $on_sale_key ] += $on_sale_ids; |
| 206 | } |
| 207 | |
| 208 | $catalog_visibility = $request->get_param( 'catalog_visibility' ); |
| 209 | $rating = $request->get_param( 'rating' ); |
| 210 | $visibility_options = wc_get_product_visibility_options(); |
| 211 | |
| 212 | if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) { |
| 213 | $exclude_from_catalog = CatalogVisibility::SEARCH === $catalog_visibility ? '' : 'exclude-from-catalog'; |
| 214 | $exclude_from_search = CatalogVisibility::CATALOG === $catalog_visibility ? '' : 'exclude-from-search'; |
| 215 | |
| 216 | $args['tax_query'][] = array( |
| 217 | 'taxonomy' => 'product_visibility', |
| 218 | 'field' => 'name', |
| 219 | 'terms' => array( $exclude_from_catalog, $exclude_from_search ), |
| 220 | 'operator' => CatalogVisibility::HIDDEN === $catalog_visibility ? 'AND' : 'NOT IN', |
| 221 | 'rating_filter' => true, |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | if ( $rating ) { |
| 226 | $rating_terms = array(); |
| 227 | foreach ( $rating as $value ) { |
| 228 | $rating_terms[] = 'rated-' . $value; |
| 229 | } |
| 230 | $args['tax_query'][] = array( |
| 231 | 'taxonomy' => 'product_visibility', |
| 232 | 'field' => 'name', |
| 233 | 'terms' => $rating_terms, |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | $orderby = $request->get_param( 'orderby' ); |
| 238 | $order = $request->get_param( 'order' ); |
| 239 | |
| 240 | $ordering_args = wc()->query->get_catalog_ordering_args( $orderby, $order ); |
| 241 | $args['orderby'] = $ordering_args['orderby']; |
| 242 | $args['order'] = $ordering_args['order']; |
| 243 | |
| 244 | if ( 'include' === $orderby ) { |
| 245 | $args['orderby'] = 'post__in'; |
| 246 | } elseif ( 'id' === $orderby ) { |
| 247 | $args['orderby'] = 'ID'; // ID must be capitalized. |
| 248 | } elseif ( 'slug' === $orderby ) { |
| 249 | $args['orderby'] = 'name'; |
| 250 | } |
| 251 | |
| 252 | if ( $ordering_args['meta_key'] ) { |
| 253 | $args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore |
| 254 | } |
| 255 | |
| 256 | // Filter by related products. |
| 257 | if ( ! empty( $request['related'] ) ) { |
| 258 | $product_id = absint( $request['related'] ); |
| 259 | $related_product = wc_get_product( $product_id ); |
| 260 | |
| 261 | if ( ! $related_product || ! $related_product->is_visible() ) { |
| 262 | throw new RouteException( |
| 263 | 'woocommerce_rest_product_not_found', |
| 264 | __( 'The related product ID is invalid or the product is not visible.', 'woocommerce' ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML. |
| 265 | 404 |
| 266 | ); |
| 267 | } |
| 268 | |
| 269 | $limit = ! empty( $request['per_page'] ) ? (int) $request['per_page'] : 100; |
| 270 | $related = wc_get_related_products( $product_id, $limit ); |
| 271 | |
| 272 | if ( ! empty( $related ) ) { |
| 273 | $args['post__in'] = ! empty( $args['post__in'] ) |
| 274 | ? array_values( array_intersect( $args['post__in'], $related ) ) |
| 275 | : array_values( $related ); |
| 276 | } else { |
| 277 | // No related products found, return empty result. |
| 278 | $args['post__in'] = array( 0 ); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | return $args; |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Convert the tax_query to a meta_query which is needed to support filtering by attributes for variations. |
| 287 | * |
| 288 | * @param array $tax_query The tax_query to convert. |
| 289 | * @return array |
| 290 | */ |
| 291 | public function convert_tax_query_to_meta_query( $tax_query ) { |
| 292 | $meta_query = array(); |
| 293 | |
| 294 | foreach ( $tax_query as $tax_query_item ) { |
| 295 | $taxonomy = $tax_query_item['taxonomy']; |
| 296 | $terms = $tax_query_item['terms']; |
| 297 | |
| 298 | $meta_key = 'attribute_' . $taxonomy; |
| 299 | |
| 300 | $meta_query[] = array( |
| 301 | 'key' => $meta_key, |
| 302 | 'value' => $terms, |
| 303 | ); |
| 304 | |
| 305 | if ( isset( $tax_query_item['operator'] ) ) { |
| 306 | $meta_query[0]['compare'] = $tax_query_item['operator']; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | return $meta_query; |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Get results of query. |
| 315 | * |
| 316 | * @param \WP_REST_Request $request Request data. |
| 317 | * @return array |
| 318 | */ |
| 319 | public function get_results( $request ) { |
| 320 | $query_args = $this->prepare_objects_query( $request ); |
| 321 | |
| 322 | add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 ); |
| 323 | |
| 324 | $query = new \WP_Query(); |
| 325 | $results = $query->query( $query_args ); |
| 326 | $total_posts = $query->found_posts; |
| 327 | |
| 328 | // Out-of-bounds, run the query again without LIMIT for total count. |
| 329 | if ( $total_posts < 1 && $query_args['paged'] > 1 ) { |
| 330 | unset( $query_args['paged'] ); |
| 331 | $count_query = new \WP_Query(); |
| 332 | $count_query->query( $query_args ); |
| 333 | $total_posts = $count_query->found_posts; |
| 334 | } |
| 335 | |
| 336 | remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 ); |
| 337 | |
| 338 | return array( |
| 339 | 'results' => $results, |
| 340 | 'total' => (int) $total_posts, |
| 341 | 'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1, |
| 342 | ); |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Get objects. |
| 347 | * |
| 348 | * @param \WP_REST_Request $request Request data. |
| 349 | * @return array |
| 350 | */ |
| 351 | public function get_objects( $request ) { |
| 352 | $results = $this->get_results( $request ); |
| 353 | |
| 354 | if ( ! empty( $results['results'] ) ) { |
| 355 | // Prime caches to reduce future queries. |
| 356 | _prime_post_caches( $results['results'] ); |
| 357 | } |
| 358 | |
| 359 | return array( |
| 360 | 'objects' => array_map( 'wc_get_product', $results['results'] ), |
| 361 | 'total' => $results['total'], |
| 362 | 'pages' => $results['pages'], |
| 363 | ); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Get last modified date for all products as an HTTP-date (RFC 7232). |
| 368 | * |
| 369 | * The result is cached in the 'wc_products' object cache group and invalidated via the |
| 370 | * clean_post_cache hook in WC_Post_Data::invalidate_products_last_modified(). |
| 371 | * |
| 372 | * Note: This intentionally does NOT use WordPress core's wp_cache_get_last_changed() / |
| 373 | * wp_cache_set_last_changed() pattern. Those functions are designed for opaque cache-key |
| 374 | * salting where auto-seeding with the current time on a cache miss is acceptable (a wrong |
| 375 | * salt simply causes a cache miss and re-query). Here, the value is exposed to clients via |
| 376 | * the Last-Modified HTTP header for collection cache invalidation. Auto-seeding with "now" |
| 377 | * on a cache miss would force all clients to unnecessarily invalidate their local caches. |
| 378 | * Instead, on a cache miss we fall back to the database to get the real last modification |
| 379 | * time and cache that. |
| 380 | * |
| 381 | * @return string|null HTTP-date formatted string, or null if no products exist. |
| 382 | */ |
| 383 | public function get_last_modified() { |
| 384 | $last_modified = wp_cache_get( 'last_modified', 'wc_products' ); |
| 385 | |
| 386 | if ( false === $last_modified ) { |
| 387 | global $wpdb; |
| 388 | |
| 389 | $last_modified_gmt = $wpdb->get_var( |
| 390 | "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' )" |
| 391 | ); |
| 392 | |
| 393 | if ( ! $last_modified_gmt ) { |
| 394 | return null; |
| 395 | } |
| 396 | |
| 397 | $last_modified = gmdate( 'D, d M Y H:i:s', strtotime( $last_modified_gmt ) ) . ' GMT'; |
| 398 | wp_cache_set( 'last_modified', $last_modified, 'wc_products' ); |
| 399 | } |
| 400 | |
| 401 | return $last_modified; |
| 402 | } |
| 403 | |
| 404 | /** |
| 405 | * Add in conditional search filters for products. |
| 406 | * |
| 407 | * @param array $args Query args. |
| 408 | * @param \WP_Query $wp_query WP_Query object. |
| 409 | * @return array |
| 410 | */ |
| 411 | public function add_query_clauses( array $args, \WP_Query $wp_query ): array { |
| 412 | global $wpdb; |
| 413 | |
| 414 | if ( $wp_query->get( 'search' ) ) { |
| 415 | $search = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%'; |
| 416 | $search_query = wc_product_sku_enabled() |
| 417 | ? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search ) |
| 418 | : $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search ); |
| 419 | $args['where'] .= $search_query; |
| 420 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 421 | } |
| 422 | |
| 423 | if ( $wp_query->get( 'sku' ) ) { |
| 424 | $skus = explode( ',', $wp_query->get( 'sku' ) ); |
| 425 | // Include the current string as a SKU too. |
| 426 | if ( 1 < count( $skus ) ) { |
| 427 | $skus[] = $wp_query->get( 'sku' ); |
| 428 | } |
| 429 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 430 | $args['where'] .= ' AND wc_product_meta_lookup.sku IN (\'' . implode( '\',\'', array_map( 'esc_sql', $skus ) ) . '\')'; |
| 431 | } |
| 432 | |
| 433 | if ( $wp_query->get( 'slug' ) ) { |
| 434 | $slugs = explode( ',', $wp_query->get( 'slug' ) ); |
| 435 | // Include the current string as a slug too. |
| 436 | if ( 1 < count( $slugs ) ) { |
| 437 | $slugs[] = $wp_query->get( 'slug' ); |
| 438 | } |
| 439 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 440 | $post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) ); |
| 441 | $args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")"; |
| 442 | } |
| 443 | |
| 444 | if ( $wp_query->get( 'stock_status' ) ) { |
| 445 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 446 | $args['where'] .= ' AND wc_product_meta_lookup.stock_status IN (\'' . implode( '\',\'', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '\')'; |
| 447 | } elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) { |
| 448 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 449 | $args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN (\'outofstock\')'; |
| 450 | } |
| 451 | |
| 452 | if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) { |
| 453 | $args = $this->add_price_filter_clauses( $args, $wp_query ); |
| 454 | } |
| 455 | |
| 456 | return $args; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Add in conditional price filters. |
| 461 | * |
| 462 | * @param array $args Query args. |
| 463 | * @param \WC_Query $wp_query WC_Query object. |
| 464 | * @return array |
| 465 | */ |
| 466 | protected function add_price_filter_clauses( $args, $wp_query ) { |
| 467 | global $wpdb; |
| 468 | |
| 469 | $adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes(); |
| 470 | $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); |
| 471 | |
| 472 | if ( $wp_query->get( 'min_price' ) ) { |
| 473 | $min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) ); |
| 474 | |
| 475 | if ( $adjust_for_taxes ) { |
| 476 | $args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' ); |
| 477 | } else { |
| 478 | $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price >= %f ', $min_price_filter ); |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | if ( $wp_query->get( 'max_price' ) ) { |
| 483 | $max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) ); |
| 484 | |
| 485 | if ( $adjust_for_taxes ) { |
| 486 | $args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' ); |
| 487 | } else { |
| 488 | $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price <= %f ', $max_price_filter ); |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | return $args; |
| 493 | } |
| 494 | |
| 495 | /** |
| 496 | * Get query for price filters when dealing with displayed taxes. |
| 497 | * |
| 498 | * @param float $price_filter Price filter to apply. |
| 499 | * @param string $column Price being filtered (min or max). |
| 500 | * @param string $operator Comparison operator for column. |
| 501 | * @return string Constructed query. |
| 502 | */ |
| 503 | protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) { |
| 504 | global $wpdb; |
| 505 | |
| 506 | // Select only used tax classes to avoid unwanted calculations. |
| 507 | $product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" ); |
| 508 | |
| 509 | if ( empty( $product_tax_classes ) ) { |
| 510 | return ''; |
| 511 | } |
| 512 | |
| 513 | $or_queries = array(); |
| 514 | |
| 515 | // We need to adjust the filter for each possible tax class and combine the queries into one. |
| 516 | foreach ( $product_tax_classes as $tax_class ) { |
| 517 | $adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class ); |
| 518 | $or_queries[] = $wpdb->prepare( |
| 519 | '( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )', |
| 520 | $tax_class, |
| 521 | $adjusted_price_filter |
| 522 | ); |
| 523 | } |
| 524 | |
| 525 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared |
| 526 | return $wpdb->prepare( |
| 527 | ' AND ( |
| 528 | wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ') |
| 529 | OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f ) |
| 530 | ) ', |
| 531 | $price_filter |
| 532 | ); |
| 533 | // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared |
| 534 | } |
| 535 | |
| 536 | /** |
| 537 | * If price filters need adjustment to work with displayed taxes, this returns true. |
| 538 | * |
| 539 | * This logic is used when prices are stored in the database differently to how they are being displayed, with regards |
| 540 | * to taxes. |
| 541 | * |
| 542 | * @return boolean |
| 543 | */ |
| 544 | protected function adjust_price_filters_for_displayed_taxes() { |
| 545 | $display = get_option( 'woocommerce_tax_display_shop' ); |
| 546 | $database = wc_prices_include_tax() ? 'incl' : 'excl'; |
| 547 | |
| 548 | return $display !== $database; |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * Converts price filter from subunits to decimal. |
| 553 | * |
| 554 | * @param string|int $price_filter Raw price filter in subunit format. |
| 555 | * @return float Price filter in decimal format. |
| 556 | */ |
| 557 | protected function prepare_price_filter( $price_filter ) { |
| 558 | return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) ); |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes. |
| 563 | * |
| 564 | * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core. |
| 565 | * |
| 566 | * @param float $price_filter Price filter amount as entered. |
| 567 | * @param string $tax_class Tax class for adjustment. |
| 568 | * @return float |
| 569 | */ |
| 570 | protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) { |
| 571 | $tax_display = get_option( 'woocommerce_tax_display_shop' ); |
| 572 | $tax_rates = WC_Tax::get_rates( $tax_class ); |
| 573 | $base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class ); |
| 574 | |
| 575 | // If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax. |
| 576 | if ( 'incl' === $tax_display ) { |
| 577 | /** |
| 578 | * Filters if taxes should be removed from locations outside the store base location. |
| 579 | * |
| 580 | * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing |
| 581 | * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10 |
| 582 | * regardless of location and taxes. |
| 583 | * |
| 584 | * @since 2.6.0 |
| 585 | * |
| 586 | * @internal Matches filter name in WooCommerce core. |
| 587 | * |
| 588 | * @param boolean $adjust_non_base_location_prices True by default. |
| 589 | * @return boolean |
| 590 | */ |
| 591 | $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 ); |
| 592 | return $price_filter - array_sum( $taxes ); |
| 593 | } |
| 594 | |
| 595 | // If prices are shown excl. tax, add taxes to match the prices stored in the DB. |
| 596 | $taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false ); |
| 597 | |
| 598 | return $price_filter + array_sum( $taxes ); |
| 599 | } |
| 600 | |
| 601 | /** |
| 602 | * Join wc_product_meta_lookup to posts if not already joined. |
| 603 | * |
| 604 | * @param string $sql SQL join. |
| 605 | * @return string |
| 606 | */ |
| 607 | protected function append_product_sorting_table_join( $sql ) { |
| 608 | global $wpdb; |
| 609 | |
| 610 | if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { |
| 611 | $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; |
| 612 | } |
| 613 | return $sql; |
| 614 | } |
| 615 | } |
| 616 |