Changeset 3736631
- Timestamp:
- 10/09/2026 12:30:15 PM (2 weeks ago)
- Location:
- wpmelon-advanced-bulk-edit-for-woocommerce
- Files:
-
- 14 added
- 6 deleted
- 32 edited
- 1 copied
-
tags/1.4.0 (copied) (copied from wpmelon-advanced-bulk-edit-for-woocommerce/trunk )
-
tags/1.4.0/assets/dist/.vite (deleted)
-
tags/1.4.0/assets/dist/assets/index-CaANxnOP.js (added)
-
tags/1.4.0/assets/dist/assets/main-BpRwKrNL.css (added)
-
tags/1.4.0/assets/dist/assets/main-C8TNUf1V.js (deleted)
-
tags/1.4.0/assets/dist/assets/main-DzSOq8aw.js (added)
-
tags/1.4.0/assets/dist/assets/main-Mt66QZMA.css (deleted)
-
tags/1.4.0/assets/dist/manifest.json (added)
-
tags/1.4.0/includes/Admin.php (modified) (3 diffs)
-
tags/1.4.0/includes/Handlers/Attribute_Handler.php (modified) (5 diffs)
-
tags/1.4.0/includes/Handlers/Attribute_Save_Handler.php (modified) (5 diffs)
-
tags/1.4.0/includes/Handlers/Rest_Page_Cache_Handler.php (added)
-
tags/1.4.0/includes/Helpers/Html_Loss_Detector.php (added)
-
tags/1.4.0/includes/Helpers/Product_Helper.php (modified) (4 diffs)
-
tags/1.4.0/includes/Helpers/Term_Helper.php (added)
-
tags/1.4.0/includes/Main.php (modified) (1 diff)
-
tags/1.4.0/includes/REST/Export_Controller.php (modified) (9 diffs)
-
tags/1.4.0/includes/REST/Products_Controller.php (modified) (16 diffs)
-
tags/1.4.0/includes/REST/User_Settings_Controller.php (modified) (2 diffs)
-
tags/1.4.0/includes/REST/Variations_Controller.php (modified) (3 diffs)
-
tags/1.4.0/includes/Services/Attribute_Service.php (modified) (4 diffs)
-
tags/1.4.0/includes/Services/Export_Service.php (modified) (3 diffs)
-
tags/1.4.0/includes/Services/Operations_Service.php (modified) (7 diffs)
-
tags/1.4.0/includes/Services/Product_Service.php (modified) (113 diffs)
-
tags/1.4.0/includes/Services/View_Service.php (modified) (6 diffs)
-
tags/1.4.0/readme.txt (modified) (5 diffs)
-
tags/1.4.0/wpmelon-advanced-bulk-edit-for-woocommerce.php (modified) (3 diffs)
-
trunk/assets/dist/.vite (deleted)
-
trunk/assets/dist/assets/index-CaANxnOP.js (added)
-
trunk/assets/dist/assets/main-BpRwKrNL.css (added)
-
trunk/assets/dist/assets/main-C8TNUf1V.js (deleted)
-
trunk/assets/dist/assets/main-DzSOq8aw.js (added)
-
trunk/assets/dist/assets/main-Mt66QZMA.css (deleted)
-
trunk/assets/dist/manifest.json (added)
-
trunk/includes/Admin.php (modified) (3 diffs)
-
trunk/includes/Handlers/Attribute_Handler.php (modified) (5 diffs)
-
trunk/includes/Handlers/Attribute_Save_Handler.php (modified) (5 diffs)
-
trunk/includes/Handlers/Rest_Page_Cache_Handler.php (added)
-
trunk/includes/Helpers/Html_Loss_Detector.php (added)
-
trunk/includes/Helpers/Product_Helper.php (modified) (4 diffs)
-
trunk/includes/Helpers/Term_Helper.php (added)
-
trunk/includes/Main.php (modified) (1 diff)
-
trunk/includes/REST/Export_Controller.php (modified) (9 diffs)
-
trunk/includes/REST/Products_Controller.php (modified) (16 diffs)
-
trunk/includes/REST/User_Settings_Controller.php (modified) (2 diffs)
-
trunk/includes/REST/Variations_Controller.php (modified) (3 diffs)
-
trunk/includes/Services/Attribute_Service.php (modified) (4 diffs)
-
trunk/includes/Services/Export_Service.php (modified) (3 diffs)
-
trunk/includes/Services/Operations_Service.php (modified) (7 diffs)
-
trunk/includes/Services/Product_Service.php (modified) (113 diffs)
-
trunk/includes/Services/View_Service.php (modified) (6 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/wpmelon-advanced-bulk-edit-for-woocommerce.php (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Admin.php
r3700717 r3736631 85 85 'isPro' => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO, 86 86 'cogsEnabled' => Helpers\Product_Helper::is_cogs_enabled(), 87 // Description saves run wp_kses_post only for users without 88 // unfiltered_html (Product_Service::sanitize_description_html()); the 89 // description dialog skips its pre-Apply sanitise preview when true. 90 'canUnfilteredHtml' => current_user_can( 'unfiltered_html' ), 87 91 // Whether this site's woocommerce_stock_amount filter truncates a 88 92 // decimal stock quantity to a whole number (true on a default … … 150 154 151 155 private function enqueue_prod_assets( array $config ): void { 152 $manifest_path = WCABE_G2_PLUGIN_DIR . 'assets/dist/ .vite/manifest.json';156 $manifest_path = WCABE_G2_PLUGIN_DIR . 'assets/dist/manifest.json'; 153 157 154 158 if ( ! file_exists( $manifest_path ) ) { … … 181 185 } 182 186 183 // Enqueue JS. 187 // Enqueue JS. No `ver` query string, deliberately: the lazily loaded 188 // description-editor chunk (#248) imports shared code from this entry by 189 // its bare file URL, and a module loaded as `main-X.js?ver=1.0` is a 190 // DIFFERENT module to the browser — the entry would evaluate a second 191 // time. The Vite content hash in the file name already busts caches. 192 // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion -- content-hashed file name; see above. 184 193 wp_enqueue_script( 185 194 'wcabe-g2-admin', 186 195 $dist_url . $entry['file'], 187 [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ],188 WCABE_G2_VERSION,196 [ 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-autop' ], 197 null, 189 198 true 190 199 ); 200 // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion 191 201 192 202 wp_set_script_translations( -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Handlers/Attribute_Handler.php
r3663491 r3736631 35 35 add_filter( 'wcabe_g2_filter_options', [ $this, 'inject_attribute_terms' ] ); 36 36 add_action( 'wcabe_g2_before_format_products', [ $this, 'prime_attribute_cache' ] ); 37 // Attribute_Service memoizes lookups built from the taxonomy registry. 38 add_action( 'registered_taxonomy', [ Attribute_Service::class, 'forget_taxonomy_key' ] ); 39 add_action( 'unregistered_taxonomy', [ Attribute_Service::class, 'forget_taxonomy_key' ] ); 37 40 } 38 41 … … 141 144 $data_key = 'attr_' . $taxonomy; 142 145 143 if ( ! isset( $attributes[ $taxonomy ] ) || ! ( $attributes[ $taxonomy ] instanceof \WC_Product_Attribute ) ) { 146 // WC keys the map by sanitize_title( name ), not the raw taxonomy (#206). 147 $map_key = Attribute_Service::attribute_key( $taxonomy ); 148 149 if ( ! isset( $attributes[ $map_key ] ) || ! ( $attributes[ $map_key ] instanceof \WC_Product_Attribute ) ) { 144 150 $data[ $data_key ] = [ 145 151 'terms' => [], … … 150 156 } 151 157 152 $wc_attr = $attributes[ $ taxonomy ];158 $wc_attr = $attributes[ $map_key ]; 153 159 $term_ids = $wc_attr->get_options(); 154 160 $terms = []; … … 192 198 * 193 199 * For variations, reads $variation->get_attributes() which returns 194 * [ 'pa_color' => 'red' ] (term slugs, not IDs). An empty slug 200 * term slugs (not IDs) keyed by WooCommerce's attribute-map key — 201 * sanitize_title( taxonomy ), e.g. [ 'pa_color' => 'red' ] for ASCII 202 * but [ 'pa_%d1%86%d0%b2%d0%b5%d1%82' => 'krasnyj' ] for `pa_цвет` — 203 * see Attribute_Service::attribute_key() (#206). An empty slug 195 204 * means "Any" — the variation applies to all terms of that attribute. 196 205 * … … 212 221 $taxonomy = $attr['taxonomy']; 213 222 $data_key = 'attr_' . $taxonomy; 214 $slug = $var_attrs[ $taxonomy] ?? '';223 $slug = $var_attrs[ Attribute_Service::attribute_key( $taxonomy ) ] ?? ''; 215 224 216 225 if ( '' === $slug ) { -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Handlers/Attribute_Save_Handler.php
r3663491 r3736631 108 108 } 109 109 110 // Fail closed when WooCommerce cannot round-trip this product's attributes: 111 // the save would rewrite them from WC's damaged read. Every attr_pa_* key is 112 // refused, including ones the schema no longer lists, so a stale request or 113 // a history restore gets an error instead of a silent skip. 114 $attr_keys = array_filter( 115 array_keys( $changes ), 116 static fn( $key ) => is_string( $key ) && 0 === strncmp( $key, 'attr_pa_', 8 ) 117 ); 118 if ( ! empty( $attr_keys ) ) { 119 $unsafe = Attribute_Service::unsafe_attribute_labels( $product ); 120 if ( ! empty( $unsafe ) ) { 121 foreach ( $attr_keys as $data_key ) { 122 $errors[] = [ 123 'field' => $data_key, 124 'value' => $changes[ $data_key ], 125 'message' => Attribute_Service::unsafe_attributes_message( $unsafe ), 126 ]; 127 } 128 return $errors; 129 } 130 } 131 110 132 $lookup = self::get_field_lookup(); 111 133 … … 196 218 $attr_def = $lookup[ $data_key ]; 197 219 $taxonomy = $attr_def['taxonomy']; 220 // WC keys the map by sanitize_title( name ), not the raw taxonomy (#206). 221 $map_key = Attribute_Service::attribute_key( $taxonomy ); 198 222 $terms = $value['terms']; 199 223 $visible = isset( $value['visible'] ) ? (bool) $value['visible'] : true; … … 205 229 if ( empty( $term_ids ) ) { 206 230 // No terms — remove the attribute from the product. 207 unset( $attributes[ $ taxonomy ] );231 unset( $attributes[ $map_key ] ); 208 232 $modified = true; 209 233 continue; … … 213 237 // Clone existing attributes so WC's set_prop() detects the change 214 238 // (it compares object identity via !==, not internal state). 215 if ( isset( $attributes[ $ taxonomy ] ) && $attributes[ $taxonomy ] instanceof \WC_Product_Attribute ) {216 $wc_attr = clone $attributes[ $ taxonomy ];239 if ( isset( $attributes[ $map_key ] ) && $attributes[ $map_key ] instanceof \WC_Product_Attribute ) { 240 $wc_attr = clone $attributes[ $map_key ]; 217 241 } else { 218 242 $wc_attr = new \WC_Product_Attribute(); … … 234 258 $wc_attr->set_variation( $variation ); 235 259 236 $attributes[ $ taxonomy ] = $wc_attr;237 $modified = true;260 $attributes[ $map_key ] = $wc_attr; 261 $modified = true; 238 262 } 239 263 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Helpers/Product_Helper.php
r3700717 r3736631 7 7 8 8 namespace WCABE_G2\Helpers; 9 10 use WCABE_G2\Services\Attribute_Service; 9 11 10 12 defined( 'ABSPATH' ) || exit; … … 264 266 'slug' => urldecode( $product->get_slug() ), 265 267 'sku' => $product->get_sku(), 268 // GTIN / UPC / EAN / ISBN (#250). 'edit' context: the stored value, the 269 // same read as the sort (lookup table), history capture and the save. 270 'global_unique_id' => $product->get_global_unique_id( 'edit' ), 266 271 'type' => $product->get_type(), 267 272 'status' => $product->get_status(), … … 407 412 ksort( $attrs ); 408 413 $term_names = []; 409 foreach ( $attrs as $ taxonomy => $slug ) {414 foreach ( $attrs as $key => $slug ) { 410 415 if ( '' === $slug ) { 411 416 continue; // Skip "Any" attributes. 412 417 } 413 $term = get_term_by( 'slug', $slug, $taxonomy ); 418 // Keys are WC's attribute-map keys, not taxonomy names — they differ for 419 // non-Latin attribute names (#245). Null = custom attribute: raw value. 420 $taxonomy = Attribute_Service::taxonomy_for_key( (string) $key ); 421 $term = null !== $taxonomy ? get_term_by( 'slug', $slug, $taxonomy ) : false; 414 422 if ( $term && ! is_wp_error( $term ) ) { 415 423 $term_names[] = $term->name; … … 694 702 // urldecode(): see format_product() — readable form of a percent-encoded slug. 695 703 'slug' => urldecode( $variation->get_slug() ), 696 'sku' => $variation->get_sku(), 704 // 'edit' context: the variation's OWN SKU, empty when it has none. The view 705 // getter inherits the parent SKU whenever the own value is empty() — which 706 // also swallows the valid SKU "0" — so a cleared SKU looked unsaved and the 707 // cell disagreed with the SKU filter, which matches on the own _sku meta 708 // (#118, #178). Same derivation as the ref map in build_product_ref_map() 709 // above and Product_Service::variation_matches_filters(). 710 'sku' => $variation->get_sku( 'edit' ), 711 // A variation's own GTIN — WooCommerce never inherits the parent's (#250). 712 'global_unique_id' => $variation->get_global_unique_id( 'edit' ), 697 713 'type' => 'variation', 698 714 'status' => $variation->get_status(), -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Main.php
r3663491 r3736631 42 42 new Handlers\Debug_Trace_Handler(); 43 43 44 // Page-cache opt-out — keeps cache plugins from serving stale REST responses. 45 new Handlers\Rest_Page_Cache_Handler(); 46 44 47 // WordPress save-hook compatibility — Free feature, controlled by plugin setting. 45 48 new Handlers\WP_Save_Hooks_Handler(); -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/Export_Controller.php
r3700717 r3736631 11 11 use WCABE_G2\Helpers\Product_Helper; 12 12 use WCABE_G2\Services\Export_Service; 13 use WCABE_G2\Services\Filter_Service;14 13 use WCABE_G2\Services\Product_Service; 15 14 use WCABE_G2\Services\View_Service; … … 86 85 'sanitize_callback' => 'sanitize_text_field', 87 86 ], 87 'search_description' => [ 88 'description' => __( 'Search by product description.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 89 'type' => 'string', 90 'sanitize_callback' => 'sanitize_text_field', 91 ], 92 'search_short_description' => [ 93 'description' => __( 'Search by product short description.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 94 'type' => 'string', 95 'sanitize_callback' => 'sanitize_text_field', 96 ], 97 // Shared with the products route — its sanitiser must not 98 // HTML-encode `<` / `>` (see Products_Controller::id_operator_arg()). 99 'id_operator' => Products_Controller::id_operator_arg(), 100 'id_value' => [ 101 'description' => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 102 'type' => 'string', 103 'sanitize_callback' => 'sanitize_text_field', 104 ], 88 105 'product_url_operator' => [ 89 106 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 104 121 'sanitize_callback' => 'sanitize_text_field', 105 122 ], 123 'category_operator' => Products_Controller::category_operator_arg(), 106 124 'tag' => [ 107 125 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 134 152 'type' => 'string', 135 153 ], 136 'price_min' => [ 137 'description' => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 138 'type' => 'string', 139 'sanitize_callback' => 'sanitize_text_field', 140 ], 141 'price_max' => [ 142 'description' => __( 'Maximum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 143 'type' => 'string', 144 'sanitize_callback' => 'sanitize_text_field', 145 ], 146 'stock_qty_min' => [ 147 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 148 'type' => 'string', 149 'sanitize_callback' => 'sanitize_text_field', 150 ], 151 'stock_qty_max' => [ 152 'description' => __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 153 'type' => 'string', 154 'sanitize_callback' => 'sanitize_text_field', 155 ], 154 // Numeric range bounds share the grid's definitions so a non-numeric 155 // value is a 400 here too (see Products_Controller::numeric_filter_arg()). 156 'price_min' => Products_Controller::numeric_filter_arg( 157 __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 158 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 159 ), 160 'price_max' => Products_Controller::numeric_filter_arg( 161 __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 162 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 163 ), 164 'stock_qty_min' => Products_Controller::numeric_filter_arg( 165 __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 166 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 167 ), 168 'stock_qty_max' => Products_Controller::numeric_filter_arg( 169 __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 170 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 171 ), 156 172 'menu_order_min' => [ 157 173 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 182 198 }, 183 199 ], 184 'cogs_min' => [ 185 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 186 'type' => 'string', 187 'sanitize_callback' => 'sanitize_text_field', 188 ], 189 'cogs_max' => [ 190 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 191 'type' => 'string', 192 'sanitize_callback' => 'sanitize_text_field', 193 ], 200 'cogs_min' => Products_Controller::numeric_filter_arg( 201 __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 202 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 203 ), 204 'cogs_max' => Products_Controller::numeric_filter_arg( 205 __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 206 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 207 ), 194 208 'manage_stock' => [ 195 209 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 291 305 'validate_callback' => [ Products_Controller::class, 'validate_orderby' ], 292 306 ], 293 'order' => [ 294 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 295 'type' => 'string', 296 'default' => 'desc', 297 'enum' => [ 'asc', 'desc' ], 298 // Same reason as `delimiter` below: a hand-registered arg gets no 299 // default validate_callback, so the enum needs this to be enforced. 300 'validate_callback' => 'rest_validate_request_arg', 301 'sanitize_callback' => 'sanitize_text_field', 302 ], 307 // Shared with the products and variations routes — see 308 // Products_Controller::sort_dir_arg(). 309 Products_Controller::SORT_DIR_PARAM => Products_Controller::sort_dir_arg(), 310 'order' => Products_Controller::legacy_order_arg(), 303 311 'delimiter' => [ 304 312 'description' => __( 'CSV field delimiter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 460 468 // order, which the client already builds from the grid's display order (#225). 461 469 $orderby = (string) $request->get_param( 'orderby' ); 462 $order = (string) $request->get_param( 'order');470 $order = Products_Controller::resolve_sort_direction( $request ); 463 471 464 472 $units = [ … … 513 521 514 522 /** 515 * Extract filter parameters from the request. 516 * 517 * Protected rather than private as a TEST SEAM: export() itself ends in 523 * The export's filter set: the grid's own builder, so a filtered export 524 * applies exactly the filters the grid applies. A hand-kept copy here once 525 * silently dropped the ID and description filters (#275). 526 * 527 * Protected rather than inlined as a TEST SEAM: export() itself ends in 518 528 * exit(), so it cannot run under PHPUnit — a test subclass exposes this 519 529 * method instead, which is what keeps the filter→CSV bridge covered. … … 523 533 */ 524 534 protected function extract_filters( \WP_REST_Request $request ): array { 525 $filters = []; 526 527 $search_name = $request->get_param( 'search_name' ); 528 if ( ! empty( $search_name ) ) { 529 $filters['search_name'] = sanitize_text_field( $search_name ); 530 } 531 $search_name_mode = $request->get_param( 'search_name_mode' ); 532 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 533 $filters['search_name_mode'] = $search_name_mode; 534 } 535 // Strict '' test, not empty(): "0" is a valid SKU. 536 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 537 if ( '' !== $search_sku ) { 538 $filters['search_sku'] = $search_sku; 539 } 540 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 541 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 542 $filters['search_sku_mode'] = $search_sku_mode; 543 } 544 545 // Product URL filter. Deliberately NOT gated on a non-empty value — the 546 // is_empty / is_not_empty operators are effective with none. 547 // Mirrors Products_Controller::get_items(); keep the two in step. 548 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 549 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 550 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 551 $filters['product_url_operator'] = $product_url_operator; 552 $filters['product_url_value'] = $product_url_value; 553 } 554 555 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 556 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: an export 557 // that silently widens back to the whole catalog is the worse failure. The presence 558 // test is deliberately `'' !==` rather than `! empty()` — empty('0') is true, so the 559 // single most likely corrupt value would otherwise slip through as "no filter". 560 // Mirrors Products_Controller::get_items(); keep the two in step. 561 $category = $request->get_param( 'category' ); 562 if ( null !== $category && '' !== $category ) { 563 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 564 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 565 } 566 567 $tag = $request->get_param( 'tag' ); 568 if ( null !== $tag && '' !== $tag ) { 569 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 570 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 571 } 572 573 $type = $request->get_param( 'type' ); 574 if ( ! empty( $type ) ) { 575 $filters['type'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $type ) ) ); 576 } 577 578 $status = $request->get_param( 'status' ); 579 if ( ! empty( $status ) ) { 580 $filters['status'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $status ) ) ); 581 } 582 583 $stock_status = $request->get_param( 'stock_status' ); 584 if ( ! empty( $stock_status ) ) { 585 $filters['stock_status'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $stock_status ) ) ); 586 } 587 588 $filters = Filter_Service::apply_shipping_class_param( 589 $request->get_param( 'shipping_class' ), 590 $filters 591 ); 592 593 $price_min = $request->get_param( 'price_min' ); 594 if ( null !== $price_min && '' !== $price_min ) { 595 $filters['price_min'] = sanitize_text_field( $price_min ); 596 } 597 598 $price_max = $request->get_param( 'price_max' ); 599 if ( null !== $price_max && '' !== $price_max ) { 600 $filters['price_max'] = sanitize_text_field( $price_max ); 601 } 602 603 // Cost of Goods range — gated so a stale param can't trigger the COGS query 604 // path when the feature is off (matches Products_Controller). 605 if ( Product_Helper::is_cogs_enabled() ) { 606 $cogs_min = $request->get_param( 'cogs_min' ); 607 if ( null !== $cogs_min && '' !== $cogs_min ) { 608 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 609 } 610 611 $cogs_max = $request->get_param( 'cogs_max' ); 612 if ( null !== $cogs_max && '' !== $cogs_max ) { 613 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 614 } 615 } 616 617 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 618 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { 619 $filters['stock_qty_min'] = sanitize_text_field( $stock_qty_min ); 620 } 621 622 $stock_qty_max = $request->get_param( 'stock_qty_max' ); 623 if ( null !== $stock_qty_max && '' !== $stock_qty_max ) { 624 $filters['stock_qty_max'] = sanitize_text_field( $stock_qty_max ); 625 } 626 627 $menu_order_min = $request->get_param( 'menu_order_min' ); 628 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 629 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 630 } 631 632 $menu_order_max = $request->get_param( 'menu_order_max' ); 633 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 634 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 635 } 636 637 $manage_stock = $request->get_param( 'manage_stock' ); 638 if ( ! empty( $manage_stock ) ) { 639 $filters['manage_stock'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $manage_stock ) ) ); 640 } 641 642 $backorders = $request->get_param( 'backorders' ); 643 if ( ! empty( $backorders ) ) { 644 $filters['backorders'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $backorders ) ) ); 645 } 646 647 $date_params = [ 648 'date_created_after', 649 'date_created_before', 650 'date_modified_after', 651 'date_modified_before', 652 'sale_start_after', 653 'sale_start_before', 654 'sale_end_after', 655 'sale_end_before', 656 'image_date_after', 657 'image_date_before', 658 ]; 659 foreach ( $date_params as $param ) { 660 $val = $request->get_param( $param ); 661 if ( ! empty( $val ) ) { 662 $filters[ $param ] = sanitize_text_field( $val ); 663 } 664 } 665 666 $attribute_filter = $request->get_param( 'attribute_filter' ); 667 if ( is_array( $attribute_filter ) && ! empty( $attribute_filter ) ) { 668 $sanitized = []; 669 foreach ( $attribute_filter as $slug => $ids_string ) { 670 // sanitize_text_field(), not sanitize_key(): WC registers non-Latin 671 // attribute names verbatim (e.g. `pa_цвет`), which sanitize_key() 672 // would collapse to `pa_`. This enforces the pa_* shape only; 673 // existence is gated in Product_Service via taxonomy_exists(). 674 $sanitized_slug = sanitize_text_field( (string) $slug ); 675 // Only allow pa_* taxonomy slugs. 676 if ( strncmp( $sanitized_slug, 'pa_', 3 ) !== 0 ) { 677 continue; 678 } 679 $ids = array_filter( array_map( 'absint', explode( ',', (string) $ids_string ) ) ); 680 if ( ! empty( $ids ) ) { 681 $sanitized[ $sanitized_slug ] = $ids; 682 } 683 } 684 if ( ! empty( $sanitized ) ) { 685 $filters['attribute_filter'] = $sanitized; 686 } 687 } 688 689 /** 690 * Allow extensions (Pro) to add filter slots from the request. 691 * 692 * @param array $filters Sanitized filter array. 693 * @param \WP_REST_Request $request Incoming request. 694 */ 695 return apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 535 return Products_Controller::build_filters( $request ); 696 536 } 697 537 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/Products_Controller.php
r3700717 r3736631 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Html_Loss_Detector; 10 11 use WCABE_G2\Helpers\Page_Size_Helper; 11 12 use WCABE_G2\Helpers\Permission_Helper; … … 26 27 const BATCH_LIMIT = 100; 27 28 29 /** 30 * Request param carrying the server-sort direction. Deliberately not `order`: 31 * third-party code on real sites treats any `?order=<x>` as an order id and 32 * redirects the request to its edit screen, so an ascending sort came back as 33 * wp-admin HTML (#254). `order` stays accepted as a legacy alias — see 34 * resolve_sort_direction(). 35 */ 36 const SORT_DIR_PARAM = 'sort_dir'; 37 28 38 protected $namespace = 'wcabe/g2'; 29 39 protected $rest_base = 'products'; … … 44 54 45 55 public function register_routes(): void { 56 // Built once: it runs `wcabe_g2_rest_query_args` on every REST request. 57 $collection_params = $this->get_collection_params(); 58 46 59 register_rest_route( $this->namespace, '/' . $this->rest_base, [ 47 60 [ … … 49 62 'callback' => [ $this, 'get_items' ], 50 63 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 51 'args' => $this->get_collection_params(), 64 'args' => $collection_params, 65 ], 66 ] ); 67 68 // The "with variations" row count for a filter set (#274), split from the 69 // products list so page and sort changes do not re-run it. 70 register_rest_route( $this->namespace, '/' . $this->rest_base . '/variation-count', [ 71 [ 72 'methods' => \WP_REST_Server::READABLE, 73 'callback' => [ $this, 'get_variation_count' ], 74 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 75 'args' => $this->get_filter_params( $collection_params ), 76 ], 77 ] ); 78 79 // Pre-Apply preview for the description dialog (#248): runs the same 80 // sanitiser the save runs, as the requesting user, so the dialog can list 81 // what WordPress will remove before the value is staged. Read-only. 82 register_rest_route( $this->namespace, '/' . $this->rest_base . '/sanitize-html', [ 83 [ 84 'methods' => \WP_REST_Server::CREATABLE, 85 'callback' => [ $this, 'sanitize_html' ], 86 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 87 'args' => [ 88 // No sanitize_callback: the raw submitted HTML is the input under test. 89 'value' => [ 90 'description' => __( 'Description HTML to sanitise.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 91 'required' => true, 92 'type' => 'string', 93 'validate_callback' => 'rest_validate_request_arg', 94 ], 95 ], 52 96 ], 53 97 ] ); … … 194 238 195 239 /** 240 * Preview what saving this description HTML would store (#248). 241 * 196 242 * @param \WP_REST_Request $request 197 * @return \WP_REST_Response 198 */ 199 public function get_items( $request ): \WP_REST_Response { 243 * @return \WP_REST_Response `{ sanitized, losses: [{ type, tag, attribute, detail, message }] }`. 244 */ 245 public function sanitize_html( $request ): \WP_REST_Response { 246 $result = Product_Service::sanitize_description_html( (string) $request->get_param( 'value' ) ); 247 248 $losses = []; 249 foreach ( $result['losses'] as $loss ) { 250 $losses[] = $loss + [ 'message' => Html_Loss_Detector::describe( [ $loss ] )[0] ]; 251 } 252 253 return new \WP_REST_Response( 254 [ 255 'sanitized' => $result['value'], 256 'losses' => $losses, 257 ], 258 200 259 ); 260 } 261 262 /** 263 * @param \WP_REST_Request $request 264 * @return \WP_REST_Response|\WP_Error 265 */ 266 public function get_items( $request ) { 200 267 $this->raise_resource_limits(); 201 268 … … 206 273 $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) ); 207 274 $include_variations = (bool) $request->get_param( 'include_variations' ); 208 209 // Build filters array from request params. 275 $filters = self::build_filters( $request ); 276 277 // Server-side sort. Whitelisted by validate_orderby() against the service's 278 // sort-spec table (Product_Service::get_sortable_columns()) at request time; 279 // the service applies it through a posts_clauses ORDER BY override. 280 $orderby = (string) $request->get_param( 'orderby' ); 281 $order = self::resolve_sort_direction( $request ); 282 283 $service = new Product_Service(); 284 try { 285 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 286 } catch ( \RuntimeException $e ) { 287 return self::query_scope_lost_error( $e ); 288 } 289 290 $response = rest_ensure_response( $data ); 291 $response->header( 'X-WP-Total', (string) $data['total'] ); 292 $response->header( 'X-WP-TotalPages', (string) $data['total_pages'] ); 293 294 return $response; 295 } 296 297 /** 298 * The variation rows the grid adds across all matching pages when variations 299 * are interleaved (#274). Split from get_items() because it re-runs the 300 * filtered query unpaginated: the client asks once per filter set, not once 301 * per page or sort. 302 * 303 * `variation_count` is null when the `wcabe_g2_compute_variation_total` filter 304 * switches the count off. 305 * 306 * @param \WP_REST_Request $request 307 * @return \WP_REST_Response|\WP_Error 308 */ 309 public function get_variation_count( $request ) { 310 $this->raise_resource_limits(); 311 312 $service = new Product_Service(); 313 try { 314 $count = $service->get_variation_count( self::build_filters( $request ) ); 315 } catch ( \RuntimeException $e ) { 316 return self::query_scope_lost_error( $e ); 317 } 318 319 return rest_ensure_response( [ 320 'variation_count' => null === $count ? null : $count['variation_count'], 321 'is_approximate' => null === $count ? false : $count['is_approximate'], 322 ] ); 323 } 324 325 /** 326 * The service refuses to return a result set whose filters/sort it could not 327 * enforce (its query marker was stripped by another plugin). Surface that as an 328 * explicit error instead of a PHP fatal, so the grid shows a message rather 329 * than an unfiltered catalogue or a blank 500. 330 * 331 * @param \RuntimeException $e 332 * @return \WP_Error 333 */ 334 private static function query_scope_lost_error( \RuntimeException $e ): \WP_Error { 335 return new \WP_Error( 336 'wcabe_g2_query_scope_lost', 337 __( 'The product query could not be filtered because another plugin altered it. Check for plugins that rewrite WooCommerce product queries.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 338 [ 339 'status' => 500, 340 'detail' => $e->getMessage(), 341 ] 342 ); 343 } 344 345 /** 346 * Build the service's filters array from the request params. The one reader 347 * of filter params: the products list, the variation count and the CSV export 348 * (Export_Controller::extract_filters()) all call it, so all three apply the 349 * same filter set. The export once kept its own copy, which silently dropped 350 * the ID and description filters (#275). Static so the export controller can 351 * call it without an instance. 352 * 353 * @param \WP_REST_Request $request 354 * @return array 355 */ 356 public static function build_filters( \WP_REST_Request $request ): array { 210 357 $filters = []; 211 358 … … 240 387 // Do not apply sanitize_text_field to id_operator — it HTML-encodes 241 388 // `<` and `>`, which breaks the operator whitelist in Product_Service. 242 // The REST schema's enum already validates the value. 389 // The schema enum is NOT enforced (the arg has its own sanitize_callback 390 // and no validate_callback); Product_Service's whitelist is what rejects 391 // an unknown operator, falling back to '='. 243 392 $filters['id_operator'] = $request->get_param( 'id_operator' ) ?: '='; 244 393 $filters['id_value'] = sanitize_text_field( $id_value ); … … 263 412 $category = $request->get_param( 'category' ); 264 413 if ( null !== $category && '' !== $category ) { 265 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 266 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 414 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 415 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 416 $filters['category_operator'] = $request->get_param( 'category_operator' ) ?: 'in'; 267 417 } 268 418 … … 395 545 * @param \WP_REST_Request $request Incoming request. 396 546 */ 397 $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 398 399 // Server-side sort. Whitelisted by validate_orderby() against the service's 400 // sort-spec table (Product_Service::get_sortable_columns()) at request time; 401 // the service applies it through a posts_clauses ORDER BY override. 402 $orderby = (string) $request->get_param( 'orderby' ); 403 $order = (string) $request->get_param( 'order' ); 404 405 $service = new Product_Service(); 406 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 407 408 $response = rest_ensure_response( $data ); 409 $response->header( 'X-WP-Total', (string) $data['total'] ); 410 $response->header( 'X-WP-TotalPages', (string) $data['total_pages'] ); 411 412 return $response; 547 return apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 413 548 } 414 549 … … 510 645 * purpose — see the arg definition in get_collection_params(). 511 646 * 647 * Shared by the products, variations and export routes. WordPress validates 648 * args BEFORE it runs `permission_callback` (WP_REST_Server::dispatch() → 649 * has_valid_params(), and respond_to_request() skips the permission check 650 * once validation has failed), so for a caller without the capability this 651 * defers to the route's Permission_Helper::rest_permission_check() — the 652 * same can_manage() test — which answers 403. Otherwise a logged-out request 653 * would get the 400 below, listing every sortable id (Pro's ACF field names 654 * and custom meta keys included), and would run the spec derivation. 655 * 512 656 * @param mixed $value Raw request value. 513 657 * @param \WP_REST_Request $request Incoming request. … … 516 660 */ 517 661 public static function validate_orderby( $value, \WP_REST_Request $request, string $param ) { 662 if ( ! Permission_Helper::can_manage() ) { 663 return true; 664 } 665 518 666 $columns = Product_Service::get_sortable_columns(); 519 667 if ( is_string( $value ) && in_array( $value, $columns, true ) ) { … … 529 677 ), 530 678 [ 'status' => 400 ] 679 ); 680 } 681 682 /** 683 * The server-sort direction of a products / variations / export request: 684 * SORT_DIR_PARAM when the caller sent a value, else the legacy `order` (#254). 685 * 686 * "Sent a value" is a non-empty string, not has_param(): an explicit JSON 687 * `null` skips WordPress's validation (it only validates non-null params) and 688 * is then sanitized to '', and must not shadow a valid `order`. SORT_DIR_PARAM 689 * has NO default on any route (see sort_dir_arg()) — a default would make it 690 * read non-empty on every request and always shadow `order`, which keeps its 691 * 'desc' default. Outside a real dispatch (tests calling a controller with a 692 * raw request) neither has a default and this returns '', which the service 693 * reads as the default sort. 694 * 695 * @param \WP_REST_Request $request Incoming request. 696 * @return string 'asc', 'desc', or '' when neither param carries a value. 697 */ 698 public static function resolve_sort_direction( \WP_REST_Request $request ): string { 699 $direction = self::sent_sort_dir( $request ); 700 return '' !== $direction ? $direction : (string) $request->get_param( 'order' ); 701 } 702 703 /** 704 * The caller's SORT_DIR_PARAM value, or '' when it carries none (absent, null, 705 * empty, or not a string — the last two are rejected by its own validation). 706 * 707 * @param \WP_REST_Request $request Incoming request. 708 * @return string 709 */ 710 private static function sent_sort_dir( \WP_REST_Request $request ): string { 711 $value = $request->get_param( self::SORT_DIR_PARAM ); 712 return is_string( $value ) ? $value : ''; 713 } 714 715 /** 716 * Arg definition of `category_operator`, shared by the products and export routes 717 * (#147). NO `default`: the controllers read it only next to a present `category` 718 * and fall back to 'in' themselves. Validation is wired explicitly because an arg 719 * with its own sanitize_callback gets no schema validation from core, so an unknown 720 * mode is a 400 rather than a silent flip to the other set. 721 * 722 * @return array 723 */ 724 public static function category_operator_arg(): array { 725 return [ 726 'description' => __( 'Category filter mode: in (products in any of the categories, the default) or not_in (products in none of them).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 727 'type' => 'string', 728 'enum' => Product_Service::CATEGORY_OPERATORS, 729 'validate_callback' => 'rest_validate_request_arg', 730 'sanitize_callback' => 'sanitize_text_field', 731 ]; 732 } 733 734 /** 735 * Arg definition of `id_operator`, shared by the products and export routes 736 * (#275) so its sanitiser cannot drift between them: sanitize_text_field() 737 * HTML-encodes `<` and `>`, which breaks the operator whitelist match in 738 * Product_Service, so strings pass through untouched. 739 * 740 * @return array 741 */ 742 public static function id_operator_arg(): array { 743 return [ 744 'description' => __( 'ID filter operator: =, >, <, >=, <=, or expression.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 745 'type' => 'string', 746 'default' => '=', 747 'enum' => [ '=', '>', '<', '>=', '<=', 'expression' ], 748 'sanitize_callback' => static function ( $value ) { 749 return is_string( $value ) ? $value : '='; 750 }, 751 ]; 752 } 753 754 /** 755 * Arg definition of a numeric range bound (price, COGS, stock quantity), 756 * shared by the products and export routes (#279). The export once declared 757 * these without a validator, so a non-numeric bound the grid rejects reached 758 * Product_Service there and was coerced: (float) 'abc' is a COGS / stock 759 * bound of 0, and wc_format_decimal() strips a price bound's non-digits. 760 * 761 * @param string $description Arg description. 762 * @param string $invalid_message Error message for a non-numeric value. 763 * @return array 764 */ 765 public static function numeric_filter_arg( string $description, string $invalid_message ): array { 766 return [ 767 'description' => $description, 768 'type' => 'string', 769 'sanitize_callback' => 'sanitize_text_field', 770 'validate_callback' => static function ( $value ) use ( $invalid_message ) { 771 if ( '' === $value ) { 772 return true; 773 } 774 return is_numeric( $value ) ? true : new \WP_Error( 'rest_invalid_param', $invalid_message ); 775 }, 776 ]; 777 } 778 779 /** 780 * Arg definition of SORT_DIR_PARAM, shared by the products, variations and 781 * export routes so the no-default rule cannot drift between them. 782 * 783 * NO `default`: resolve_sort_direction() falls back to the legacy `order` only 784 * while SORT_DIR_PARAM reads empty. Validation is wired explicitly because an 785 * arg with its own sanitize_callback gets no schema validation from core. 786 * 787 * @return array 788 */ 789 public static function sort_dir_arg(): array { 790 return [ 791 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 792 'type' => 'string', 793 'enum' => [ 'asc', 'desc' ], 794 'validate_callback' => 'rest_validate_request_arg', 795 'sanitize_callback' => 'sanitize_text_field', 796 ]; 797 } 798 799 /** 800 * Arg definition of the legacy `order` alias, shared by the same three routes. 801 * 802 * @return array 803 */ 804 public static function legacy_order_arg(): array { 805 return [ 806 'description' => __( 'Deprecated alias of sort_dir, used only when sort_dir is absent.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 807 'type' => 'string', 808 'default' => 'desc', 809 'enum' => [ 'asc', 'desc' ], 810 'validate_callback' => [ self::class, 'validate_legacy_order' ], 811 'sanitize_callback' => 'sanitize_text_field', 812 ]; 813 } 814 815 /** 816 * `order` validate_callback: the enum is enforced only while `order` is the 817 * direction in use. When the caller also sent SORT_DIR_PARAM, `order` is 818 * ignored by resolve_sort_direction(), so a stray value — e.g. third-party 819 * code appending an `order` of its own, the #254 conflict class — must not 820 * 400 an otherwise valid request. An invalid SORT_DIR_PARAM still fails on 821 * its own validation. 822 * 823 * @param mixed $value Raw request value. 824 * @param \WP_REST_Request $request Incoming request. 825 * @param string $param Parameter name. 826 * @return true|\WP_Error 827 */ 828 public static function validate_legacy_order( $value, \WP_REST_Request $request, string $param ) { 829 if ( '' !== self::sent_sort_dir( $request ) ) { 830 return true; 831 } 832 return rest_validate_request_arg( $value, $request, $param ); 833 } 834 835 /** 836 * The collection params minus paging, sort and include_variations: the filter 837 * args alone, including any an extension adds through `wcabe_g2_rest_query_args`. 838 * Used by the variation count, whose result depends on the filters only. 839 * 840 * @param array|null $collection_params get_collection_params(), when the caller 841 * already built it. 842 * @return array 843 */ 844 public function get_filter_params( ?array $collection_params = null ): array { 845 return array_diff_key( 846 $collection_params ?? $this->get_collection_params(), 847 array_flip( [ 'page', 'per_page', 'include_variations', 'orderby', self::SORT_DIR_PARAM, 'order' ] ) 531 848 ); 532 849 } … … 578 895 'validate_callback' => [ self::class, 'validate_orderby' ], 579 896 ], 580 'order' => [ 581 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 582 'type' => 'string', 583 'default' => 'desc', 584 'enum' => [ 'asc', 'desc' ], 585 ], 897 // Shared with the variations and export routes — see sort_dir_arg(). 898 self::SORT_DIR_PARAM => self::sort_dir_arg(), 899 'order' => self::legacy_order_arg(), 586 900 'search_name' => [ 587 901 'description' => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 619 933 'sanitize_callback' => 'sanitize_text_field', 620 934 ], 621 'id_operator' => [ 622 'description' => __( 'ID filter operator: =, >, <, >=, <=, or expression.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 623 'type' => 'string', 624 'default' => '=', 625 'enum' => [ '=', '>', '<', '>=', '<=', 'expression' ], 626 'sanitize_callback' => function ( $value ) { 627 // sanitize_text_field HTML-encodes `<` and `>`, which breaks the 628 // operator whitelist match in Product_Service. The enum above 629 // already validates input, so we just pass strings through. 630 return is_string( $value ) ? $value : '='; 631 }, 632 ], 935 'id_operator' => self::id_operator_arg(), 633 936 'id_value' => [ 634 937 'description' => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 655 958 'sanitize_callback' => 'sanitize_text_field', 656 959 ], 960 'category_operator' => self::category_operator_arg(), 657 961 'tag' => [ 658 962 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 685 989 'type' => 'string', 686 990 ], 687 'price_min' => [ 688 'description' => __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 689 'type' => 'string', 690 'sanitize_callback' => 'sanitize_text_field', 691 'validate_callback' => function( $value ) { 692 if ( '' === $value ) { 693 return true; 694 } 695 return is_numeric( $value ) ? true : new \WP_Error( 696 'rest_invalid_param', 697 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 698 ); 699 }, 700 ], 701 'price_max' => [ 702 'description' => __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 703 'type' => 'string', 704 'sanitize_callback' => 'sanitize_text_field', 705 'validate_callback' => function( $value ) { 706 if ( '' === $value ) { 707 return true; 708 } 709 return is_numeric( $value ) ? true : new \WP_Error( 710 'rest_invalid_param', 711 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 712 ); 713 }, 714 ], 715 'cogs_min' => [ 716 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 717 'type' => 'string', 718 'sanitize_callback' => 'sanitize_text_field', 719 'validate_callback' => function( $value ) { 720 if ( '' === $value ) { 721 return true; 722 } 723 return is_numeric( $value ) ? true : new \WP_Error( 724 'rest_invalid_param', 725 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 726 ); 727 }, 728 ], 729 'cogs_max' => [ 730 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 731 'type' => 'string', 732 'sanitize_callback' => 'sanitize_text_field', 733 'validate_callback' => function( $value ) { 734 if ( '' === $value ) { 735 return true; 736 } 737 return is_numeric( $value ) ? true : new \WP_Error( 738 'rest_invalid_param', 739 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 740 ); 741 }, 742 ], 743 'stock_qty_min' => [ 744 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 745 'type' => 'string', 746 'sanitize_callback' => 'sanitize_text_field', 747 'validate_callback' => function( $value ) { 748 if ( '' === $value ) { 749 return true; 750 } 751 return is_numeric( $value ) ? true : new \WP_Error( 752 'rest_invalid_param', 753 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 754 ); 755 }, 756 ], 757 'stock_qty_max' => [ 758 'description' => __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 759 'type' => 'string', 760 'sanitize_callback' => 'sanitize_text_field', 761 'validate_callback' => function( $value ) { 762 if ( '' === $value ) { 763 return true; 764 } 765 return is_numeric( $value ) ? true : new \WP_Error( 766 'rest_invalid_param', 767 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 768 ); 769 }, 770 ], 991 'price_min' => self::numeric_filter_arg( 992 __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 993 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 994 ), 995 'price_max' => self::numeric_filter_arg( 996 __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 997 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 998 ), 999 'cogs_min' => self::numeric_filter_arg( 1000 __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1001 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1002 ), 1003 'cogs_max' => self::numeric_filter_arg( 1004 __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1005 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1006 ), 1007 'stock_qty_min' => self::numeric_filter_arg( 1008 __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1009 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1010 ), 1011 'stock_qty_max' => self::numeric_filter_arg( 1012 __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1013 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1014 ), 771 1015 'menu_order_min' => [ 772 1016 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/User_Settings_Controller.php
r3700717 r3736631 77 77 'enum' => [ 'adaptive', '25', '50', '100' ], 78 78 ], 79 // Last-used description dialog tab (#248). Mirrors 80 // DESCRIPTION_EDITOR_MODES in assets/src/types/api.ts. 81 'descriptionEditorMode' => [ 82 'type' => 'string', 83 'default' => 'visual', 84 'enum' => [ 'visual', 'html' ], 85 ], 79 86 ]; 80 87 } … … 204 211 } 205 212 206 update_user_meta( get_current_user_id(), self::META_KEY, $current ); 213 // wp_slash() cancels update_metadata()'s wp_unslash(); REST JSON params are 214 // unslashed, so a backslash in the filter draft would otherwise be dropped on 215 // every write (#240). 216 update_user_meta( get_current_user_id(), self::META_KEY, wp_slash( $current ) ); 207 217 208 218 // Return SCHEMA-only settings (get_all strips _rev) so _rev never surfaces -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/Variations_Controller.php
r3700717 r3736631 27 27 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 28 28 'args' => [ 29 'id' => [29 'id' => [ 30 30 'description' => __( 'Product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 31 31 'type' => 'integer', … … 33 33 'sanitize_callback' => 'absint', 34 34 ], 35 // The grid's active sort (#247): the expanded variations are 36 // ordered exactly as they would be interleaved under the parent. 37 // Same request-time validation as the products route — see the 38 // note on Products_Controller::validate_orderby(). 39 'orderby' => [ 40 'description' => __( 'Order the variations by this column.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 41 'type' => 'string', 42 'default' => 'id', 43 'validate_callback' => [ Products_Controller::class, 'validate_orderby' ], 44 ], 45 // Shared with the products and export routes — see 46 // Products_Controller::sort_dir_arg(). 47 Products_Controller::SORT_DIR_PARAM => Products_Controller::sort_dir_arg(), 48 'order' => Products_Controller::legacy_order_arg(), 35 49 ], 36 50 ], … … 48 62 49 63 $product_id = absint( $request->get_param( 'id' ) ); 64 $orderby = (string) $request->get_param( 'orderby' ); 65 $order = Products_Controller::resolve_sort_direction( $request ); 50 66 51 67 $service = new Product_Service(); 52 $result = $service->get_variations( $product_id);68 $result = $service->get_variations( $product_id, $orderby, $order ); 53 69 54 70 if ( is_wp_error( $result ) ) { -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Attribute_Service.php
r3549389 r3736631 19 19 private static $schema_cache = null; 20 20 21 /** 22 * Request-level memo for attribute_key(), raw taxonomy => map key. 23 * 24 * @var array<string, string> 25 */ 26 private static $attribute_keys = []; 27 28 /** 29 * Request-level memo for taxonomy_for_key(), attribute-map key => registered 30 * pa_* taxonomy name. Null until first needed; forget_taxonomy_key() drops it 31 * whenever a pa_* taxonomy is (un)registered. 32 * 33 * @var array<string, string>|null 34 */ 35 private static $taxonomies_by_key = null; 36 37 /** 38 * Request-level memo for attribute_registry_hazards(). Null until first needed; 39 * dropped by clear_cache() and forget_taxonomy_key(). 40 * 41 * @var array{unsafe: array<string, string>, unregistered: string[]}|null 42 */ 43 private static $registry_hazards = null; 44 21 45 // TODO: Custom per-product text attributes (non-taxonomy) are not supported. 22 46 // Global taxonomy-backed attributes only. Per-product attributes are a different … … 27 51 * 28 52 * Returns every attribute registered via WooCommerce > Attributes admin. 29 * No settings/enable step — all discovered attributes are included. 53 * No settings/enable step — all discovered attributes are included, except 54 * ones WooCommerce cannot currently round-trip (unregistered, or flagged by 55 * attribute_registry_hazards()): a column for those would feed edits into a 56 * save that converts or drops them. In a normal setup nothing is excluded. 30 57 * 31 58 * @return array Array of [ 'slug' => 'color', 'label' => 'Color', 'taxonomy' => 'pa_color', 'hierarchical' => false, 'orderby' => 'menu_order' ]. … … 42 69 } 43 70 71 $unsafe = self::attribute_registry_hazards()['unsafe']; 44 72 $result = []; 45 73 46 74 foreach ( $attribute_taxonomies as $attr ) { 47 $taxonomy = wc_attribute_taxonomy_name( $attr->attribute_name ); 48 $tax_obj = get_taxonomy( $taxonomy ); 75 // The stored-slug name — what products store. Equal to 76 // wc_attribute_taxonomy_name() whenever the row is not unsafe. 77 $taxonomy = 'pa_' . $attr->attribute_name; 78 if ( ! taxonomy_exists( $taxonomy ) || isset( $unsafe[ $taxonomy ] ) ) { 79 continue; 80 } 81 $tax_obj = get_taxonomy( $taxonomy ); 49 82 50 83 $result[] = [ … … 141 174 142 175 /** 143 * Clear the request-level schema cache (for testing). 176 * The key WooCommerce uses for a taxonomy attribute inside a product's 177 * attribute map. 178 * 179 * `WC_Product::set_attributes()` keys a parent's `WC_Product_Attribute` objects 180 * by `sanitize_title( $attribute->get_name() )`, and 181 * `wc_get_product_variation_attributes()` derives a variation's 182 * `taxonomy => slug` key from that same parent key. For an ASCII name 183 * (`pa_color`) this is the identity; for a non-Latin name it is the 184 * percent-encoded form (`pa_цвет` → `pa_%d1%86%d0%b2%d0%b5%d1%82`), so 185 * indexing `get_attributes()` by the raw taxonomy silently misses (#206). 186 * 187 * Every read of, or write into, a map that came from / goes to 188 * `get_attributes()` / `set_attributes()` (parent or variation) must go 189 * through this helper. Everything else — the attribute schema, `attr_*` 190 * column keys, term maps, `WC_Product_Attribute::set_name()` — stays on 191 * the RAW taxonomy name. `Product_Service::resolve_attribute_filter_slugs()` 192 * (#202) keys its result the same way. 193 * 194 * Memoized per request: callers sit in per-row × per-attribute loops 195 * (grid formatting, sibling signatures), and sanitize_title() runs the 196 * whole filter chain. This assumes that chain is stable once the request 197 * is handling products — the same assumption WC makes when it re-keys 198 * maps on read. 199 * 200 * @param string $taxonomy Raw taxonomy name, e.g. 'pa_цвет'. 201 * @return string WooCommerce's attribute-map key for it. 202 */ 203 public static function attribute_key( string $taxonomy ): string { 204 if ( ! isset( self::$attribute_keys[ $taxonomy ] ) ) { 205 self::$attribute_keys[ $taxonomy ] = sanitize_title( $taxonomy ); 206 } 207 return self::$attribute_keys[ $taxonomy ]; 208 } 209 210 /** 211 * Inverse of attribute_key(): the registered taxonomy behind a key read out of 212 * a `get_attributes()` map (parent or variation), or null when the key is not a 213 * global attribute (a custom per-product attribute, or a taxonomy that is no 214 * longer registered). 215 * 216 * Only `pa_*` keys qualify: a custom attribute named like a WordPress taxonomy 217 * ("Category" → `category`) would otherwise resolve through that taxonomy. 218 * A `pa_*` key that is itself a registered taxonomy (every ASCII name) resolves 219 * to itself. Otherwise the key is looked up in a map built from the REGISTERED 220 * pa_* taxonomy names, keyed through attribute_key(). Deliberately not 221 * urldecode( $key ): a transliterating `sanitize_title` filter makes the key 222 * `pa_czvet` for the taxonomy `pa_цвет`, which no decoding recovers. And not 223 * wc_get_attribute_taxonomy_names(): it re-runs sanitize_title() through 224 * wc_attribute_taxonomy_name(), so under that same filter it names a 225 * taxonomy (`pa_czvet`) that was never registered (#245). 226 * 227 * @param string $key Attribute-map key, e.g. 'pa_%d1%86%d0%b2%d0%b5%d1%82'. 228 * @return string|null Registered taxonomy name, e.g. 'pa_цвет'. 229 */ 230 public static function taxonomy_for_key( string $key ): ?string { 231 if ( 0 !== strncmp( $key, 'pa_', 3 ) ) { 232 return null; 233 } 234 235 if ( taxonomy_exists( $key ) ) { 236 return $key; 237 } 238 239 if ( null === self::$taxonomies_by_key ) { 240 self::$taxonomies_by_key = []; 241 foreach ( get_taxonomies() as $taxonomy ) { 242 if ( 0 === strncmp( $taxonomy, 'pa_', 3 ) ) { 243 self::$taxonomies_by_key[ self::attribute_key( $taxonomy ) ] = $taxonomy; 244 } 245 } 246 } 247 248 return self::$taxonomies_by_key[ $key ] ?? null; 249 } 250 251 /** 252 * Drop the memos built from the taxonomy registry (key map, registry hazards, 253 * schema) when a `pa_*` taxonomy is registered or unregistered. Hooked on 254 * `registered_taxonomy` / `unregistered_taxonomy` (Attribute_Handler); the 255 * memos otherwise live for the whole process, which a long-running WP-CLI or 256 * queue worker outlives. 257 * 258 * @param string $taxonomy Taxonomy name. 259 */ 260 public static function forget_taxonomy_key( string $taxonomy ): void { 261 if ( 0 === strncmp( $taxonomy, 'pa_', 3 ) ) { 262 self::$taxonomies_by_key = null; 263 self::$registry_hazards = null; 264 // The schema lists only registered, round-trippable attributes. 265 self::$schema_cache = null; 266 } 267 } 268 269 /** 270 * Labels of the attributes that make attribute writes on $product unsafe, or 271 * [] when WooCommerce can round-trip every attribute it carries. Every WCABE 272 * path that writes a product's attributes (parent attribute edits, variation 273 * attribute edits, duplication, variation creation) must refuse the product 274 * while this is non-empty. 275 * 276 * Decided from the attribute REGISTRY, not the loaded attributes alone: by the 277 * time a product is loaded, WooCommerce may already have collapsed two 278 * attributes onto one map key or dropped one (see attribute_registry_hazards()). 279 * A collision always leaves a member of the colliding group behind, and every 280 * member is unsafe, so the survivor flags the product whichever one won. A 281 * dropped attribute leaves nothing behind, so an unregistered stored taxonomy 282 * flags EVERY product. 283 * 284 * A variation is checked through its parent: its own get_attributes() is a 285 * key => slug map with no attribute names. 286 * 287 * @param \WC_Product $product Parent product, or a variation (its parent is checked). 288 * @return string[] Attribute labels, for the refusal message. 289 */ 290 public static function unsafe_attribute_labels( \WC_Product $product ): array { 291 $hazards = self::attribute_registry_hazards(); 292 if ( ! empty( $hazards['unregistered'] ) ) { 293 return $hazards['unregistered']; 294 } 295 if ( empty( $hazards['unsafe'] ) ) { 296 return []; 297 } 298 299 if ( $product instanceof \WC_Product_Variation ) { 300 $parent = wc_get_product( $product->get_parent_id() ); 301 if ( ! $parent ) { 302 return []; 303 } 304 $product = $parent; 305 } 306 307 $labels = []; 308 foreach ( $product->get_attributes() as $attribute ) { 309 if ( $attribute instanceof \WC_Product_Attribute && isset( $hazards['unsafe'][ $attribute->get_name() ] ) ) { 310 $labels[] = $hazards['unsafe'][ $attribute->get_name() ]; 311 } 312 } 313 return array_values( array_unique( $labels ) ); 314 } 315 316 /** 317 * The refusal message for a non-empty unsafe_attribute_labels() result. 318 * 319 * When the refusal is the site-wide one (an attribute's stored taxonomy is not 320 * registered), the product being saved may not carry the named attribute at 321 * all, so the message says the pause applies to every product and why. 322 * 323 * @param string[] $labels Attribute labels. 324 */ 325 public static function unsafe_attributes_message( array $labels ): string { 326 if ( ! empty( self::attribute_registry_hazards()['unregistered'] ) ) { 327 return sprintf( 328 /* translators: %s: comma-separated attribute names */ 329 __( 'Nothing was changed: attribute changes are paused for every product because WooCommerce has no registered taxonomy for the attribute(s) %s. Saving attributes now could remove them from the products that use them. This usually follows a change to how slugs are generated, such as a transliteration plugin.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 330 implode( ', ', $labels ) 331 ); 332 } 333 334 return sprintf( 335 /* translators: %s: comma-separated attribute names */ 336 __( 'Nothing was changed: WooCommerce cannot currently resolve the attribute(s) %s, and writing attributes now would convert or remove them.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 337 implode( ', ', $labels ) 338 ); 339 } 340 341 /** 342 * Global attributes WooCommerce cannot currently round-trip, from the registry. 343 * 344 * WooCommerce registers `pa_*` taxonomies at init:5 and re-derives names through 345 * sanitize_title() everywhere after that. When the sanitize chain changes in 346 * between (a transliterating filter hooked late), its own read path breaks: 347 * read_attributes() resolves ids through wc_attribute_taxonomy_id_by_name() (0, 348 * or ANOTHER attribute's id), set_attributes() collapses names that now sanitize 349 * to the same key, and an attribute whose stored taxonomy is not registered is 350 * dropped. Any attribute save then rewrites the whole set from that damaged view. 351 * 352 * Per attribute row, S = 'pa_' . attribute_name (the stored-slug taxonomy, which 353 * is what products store) and D = wc_attribute_taxonomy_name() (today's derivation): 354 * - S not registered → 'unregistered' (WC drops it from every product carrying it); 355 * - D !== S, or WC's id lookup for S is not this row's id → 'unsafe'; 356 * - two registered S sharing one attribute_key() → every member 'unsafe'. 357 * In a normal setup stored slugs are canonical, so both lists are empty. 358 * 359 * @return array{unsafe: array<string, string>, unregistered: string[]} 360 * 'unsafe' is taxonomy => label; 'unregistered' is labels. 361 */ 362 private static function attribute_registry_hazards(): array { 363 if ( null !== self::$registry_hazards ) { 364 return self::$registry_hazards; 365 } 366 367 $unsafe = []; 368 $unregistered = []; 369 $by_key = []; 370 371 foreach ( wc_get_attribute_taxonomies() as $attr ) { 372 $stored = 'pa_' . $attr->attribute_name; 373 $label = $attr->attribute_label ?: $attr->attribute_name; 374 375 if ( ! taxonomy_exists( $stored ) ) { 376 $unregistered[] = $label; 377 continue; 378 } 379 380 if ( 381 wc_attribute_taxonomy_name( $attr->attribute_name ) !== $stored 382 || wc_attribute_taxonomy_id_by_name( $stored ) !== (int) $attr->attribute_id 383 ) { 384 $unsafe[ $stored ] = $label; 385 } 386 387 $by_key[ self::attribute_key( $stored ) ][ $stored ] = $label; 388 } 389 390 foreach ( $by_key as $members ) { 391 if ( count( $members ) > 1 ) { 392 $unsafe += $members; 393 } 394 } 395 396 self::$registry_hazards = [ 397 'unsafe' => $unsafe, 398 'unregistered' => $unregistered, 399 ]; 400 return self::$registry_hazards; 401 } 402 403 /** 404 * Clear the request-level schema cache and attribute-key memos (for testing). 144 405 */ 145 406 public static function clear_cache(): void { 146 self::$schema_cache = null; 407 self::$schema_cache = null; 408 self::$attribute_keys = []; 409 self::$taxonomies_by_key = null; 410 self::$registry_hazards = null; 147 411 } 148 412 } -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Export_Service.php
r3700717 r3736631 72 72 'slug' => 'Slug', 73 73 'sku' => 'SKU', 74 // WooCommerce's own product CSV header, so the column maps on re-import (#250). 75 'global_unique_id' => 'GTIN, UPC, EAN, or ISBN', 74 76 'type' => 'Type', 75 77 'status' => 'Status', … … 256 258 $dimension_unit = $units['dimension_unit'] ?? ''; 257 259 260 // Attribute headers use the SAME label derivation as the grid column 261 // header (Attribute_Service schema + the plugin's own translatable 262 // '(attr) %s' format — the PHP mirror of assets/src/types/attribute-header.ts, 263 // keep the two literals byte-equal), so a fresh export round-trips through Paste from 264 // Excel's exact-label auto-map under every locale (#243). NOT 265 // get_taxonomy()->label: WP sets that to labels->name, which WooCommerce 266 // registers as 'Product %s' — the header no longer matched the grid. 267 $attribute_labels = []; 268 foreach ( Attribute_Service::get_attribute_schema() as $attr ) { 269 $attribute_labels[ $attr['taxonomy'] ] = $attr['label']; 270 } 271 258 272 $headers = []; 259 273 foreach ( $columns as $col ) { … … 268 282 } elseif ( strncmp( $col, 'attr_pa_', 8 ) === 0 ) { 269 283 $taxonomy = substr( $col, 5 ); // strip 'attr_' → 'pa_color' 270 $tax_obj = get_taxonomy( $taxonomy ); 271 $label = '(attr) ' . ( $tax_obj ? $tax_obj->label : $col ); 284 $label = sprintf( 285 /* translators: %s: attribute name */ 286 __( '(attr) %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 287 $attribute_labels[ $taxonomy ] ?? $taxonomy 288 ); 272 289 } elseif ( isset( $this->pro_columns[ $col ] ) ) { 273 290 $label = $this->pro_columns[ $col ]; -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Operations_Service.php
r3663491 r3736631 32 32 */ 33 33 public const MAX_COPIES_PER_PRODUCT = 50; 34 35 /** 36 * Candidates set_sku() may REJECT before assign_duplicate_sku() gives the copy a blank SKU. 37 * 38 * Normally one candidate suffices (the suffix scan starts the walk past every holder it can 39 * read); the bound only stops a uniqueness filter that rejects every candidate from walking 40 * forever. Candidates a lookup row already holds do not count — see MAX_HELD_SKU_PROBES. 41 */ 42 private const MAX_SKU_PROBES = 100; 43 44 /** 45 * Held candidates assign_duplicate_sku() walks past before giving the copy a blank SKU. 46 * 47 * Normally none: the suffix scan starts the walk past every holder it can read. A FAILED scan 48 * starts it at 1, and the walk then steps past each existing `-N` holder, one query each — 49 * slow, but the copy still gets its SKU. The ceiling only stops a `query` filter that makes 50 * every holder check read "held" from looping forever. Protected so a test subclass can 51 * shrink it. 52 */ 53 protected const MAX_HELD_SKU_PROBES = 10000; 54 55 /** 56 * Bases max_numeric_suffixes() scans per statement. Protected so a test subclass can shrink 57 * it to pin the per-chunk branch alignment. 58 */ 59 protected const SUFFIX_SCAN_CHUNK = 100; 60 61 /** 62 * Seconds acquire_sku_lock() waits for a concurrent duplicate's copy to save. A copy holds it 63 * for one variation save, so a wait this long means something is stuck: go ahead unlocked. 64 * Protected so a test subclass can shorten it. 65 */ 66 protected const SKU_LOCK_TIMEOUT = 5; 34 67 35 68 /** … … 74 107 * see the phase-2 comment. Count creations off created_ids; render rows off products. 75 108 * 109 * Follows WC core's own Duplicate (`WC_Admin_Duplicate_Product::product_duplicate()`) in what 110 * it resets (total sales, ratings, GTIN, status, slug, date) and fires the same current hooks, 111 * in core's order: `woocommerce_duplicate_product_exclude_meta`, 112 * `woocommerce_product_duplicate_before_save` (copy and each variation), 113 * `woocommerce_duplicate_product_exclude_children`, then `woocommerce_product_duplicate` once 114 * the copy is complete (core's admin action fires it; core's REST duplicate does not). The 115 * deprecated `woocommerce_duplicate_product` action (deprecated since WC 3.0, fired only by 116 * core's admin action) is not fired. It deliberately does not delegate to core, because it 117 * differs on purpose in these ways: 118 * - Copies are named "(Copy)", "(Copy 2)", … instead of always "(Copy)". 119 * - SKUs are assigned by assign_duplicate_sku(), which is stricter than core's: it respects the 120 * lookup column's width and WC's create-time lock predicate, and reports copies it had to 121 * leave without a SKU. 122 * - A variation that fails is counted and reported, where core would throw out of the copy. 123 * - A source whose attributes cannot be copied safely is refused. 124 * 76 125 * @param int[] $product_ids Source product IDs. 77 126 * @param int $count Number of copies to create per source product. … … 87 136 $failed = []; 88 137 $total_var_failures = 0; 138 // Highest SKU suffix handed out or probed per source SKU in THIS call — shared by every 139 // source, copy and variation so N copies cost one suffix scan per distinct parent SKU 140 // plus one batched scan per source's variations, not N. 141 $sku_cursor = []; 142 // Saved copies of a SKU'd source left without a SKU — counted apart, because the toast 143 // counts products only and a single total would read as more copies than it reports. 144 $sku_blanked_products = 0; 145 $sku_blanked_variations = 0; 146 // Saved copies whose post-save steps (variations, listeners) threw. 147 $unfinished = 0; 148 // SKU lock names that timed out in THIS call — see acquire_sku_lock(). 149 $lock_timeouts = []; 89 150 90 151 if ( empty( $product_ids ) ) { … … 116 177 } 117 178 179 // A copy is written from the source's LOADED attributes, which WooCommerce 180 // may already have downgraded, collapsed or dropped — refuse instead. 181 $unsafe = Attribute_Service::unsafe_attribute_labels( $source ); 182 if ( ! empty( $unsafe ) ) { 183 $failed[] = [ 184 'id' => $id, 185 'message' => Attribute_Service::unsafe_attributes_message( $unsafe ), 186 ]; 187 continue; 188 } 189 190 // 'edit': the copy must derive from the stored SKU, not a filtered view of it. 191 $source_sku = $source->get_sku( 'edit' ); 192 118 193 for ( $i = 1; $i <= $count; $i++ ) { 119 194 $suffix = ( 1 === $i ) ? ' (Copy)' : sprintf( ' (Copy %d)', $i ); … … 121 196 $duplicate->set_id( 0 ); 122 197 $duplicate->set_name( $source->get_name() . $suffix ); 123 $duplicate->set_sku( '' ); 124 $duplicate->set_status( 'draft' ); 125 $duplicate->set_date_created( null ); 126 $duplicate->set_slug( '' ); 127 $duplicate->set_total_sales( 0 ); 128 129 // Preserve media, taxonomy, attributes, and related products. 130 $duplicate->set_image_id( $source->get_image_id() ); 131 $duplicate->set_gallery_image_ids( $source->get_gallery_image_ids() ); 132 $duplicate->set_category_ids( $source->get_category_ids() ); 133 $duplicate->set_tag_ids( $source->get_tag_ids() ); 134 $duplicate->set_attributes( $source->get_attributes() ); 135 // 'edit' context: the view getters run through 136 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 137 // code can filter. Copying a filtered projection would bake that plugin's 138 // display-time view permanently into the duplicate's stored data. 139 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 140 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 141 142 if ( $source->is_type( 'variable' ) ) { 143 $duplicate->set_default_attributes( $source->get_default_attributes() ); 198 // Held from the SKU checks through the save — see acquire_sku_lock(). 199 $sku_lock = self::acquire_sku_lock( $source_sku, $lock_timeouts ); 200 try { 201 $sku_derived = $this->assign_duplicate_sku( $duplicate, $source_sku, $sku_cursor ); 202 // GTINs must be unique; a copied one is flagged "Invalid or duplicated GTIN" by WC's 203 // product editor on every later save of either product. Core's Duplicate clears it too. 204 $duplicate->set_global_unique_id( '' ); 205 $duplicate->set_status( 'draft' ); 206 $duplicate->set_date_created( null ); 207 $duplicate->set_slug( '' ); 208 $duplicate->set_total_sales( 0 ); 209 // A copy has no reviews of its own — core's Duplicate resets these too. 210 $duplicate->set_rating_counts( 0 ); 211 $duplicate->set_average_rating( 0 ); 212 $duplicate->set_review_count( 0 ); 213 214 // Preserve media, taxonomy, attributes, and related products. 215 $duplicate->set_image_id( $source->get_image_id() ); 216 $duplicate->set_gallery_image_ids( $source->get_gallery_image_ids() ); 217 $duplicate->set_category_ids( $source->get_category_ids() ); 218 $duplicate->set_tag_ids( $source->get_tag_ids() ); 219 $duplicate->set_attributes( $source->get_attributes() ); 220 // 'edit' context: the view getters run through 221 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 222 // code can filter. Copying a filtered projection would bake that plugin's 223 // display-time view permanently into the duplicate's stored data. 224 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 225 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 226 227 if ( $source->is_type( 'variable' ) ) { 228 $duplicate->set_default_attributes( $source->get_default_attributes() ); 229 } 230 231 // Third-party code runs from here on (the exclude-meta filter, the before-save 232 // action, save listeners), so the catch below is \Throwable: an Error escaping after 233 // earlier copies were saved would fail the request, and a retry would duplicate them 234 // again. Up to save() nothing is written, so a throw there is a truthful failure. 235 try { 236 // Core's Duplicate contract, in core's order: integrations name the meta a copy 237 // must not inherit (external sync IDs and the like), then get the unsaved copy. 238 // Before WC 10.3 made brands a product prop, WC's own Brands copied them here. 239 $meta_to_exclude = self::meta_keys_excluded_from_duplicate( $source ); 240 foreach ( $meta_to_exclude as $meta_key ) { 241 $duplicate->delete_meta_data( $meta_key ); 242 } 243 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate hook, fired so listeners treat this copy like a core duplicate. Not a hook we own. 244 do_action( 'woocommerce_product_duplicate_before_save', $duplicate, $source ); 245 246 $duplicate->save(); 247 } catch ( \Throwable $e ) { 248 $failed[] = [ 249 'id' => $id, 250 'message' => sprintf( 251 /* translators: 1: copy index, 2: total copies */ 252 __( 'Failed to duplicate product (copy %1$d of %2$d).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 253 $i, 254 $count 255 ), 256 ]; 257 continue; 258 } 259 } finally { 260 self::release_sku_lock( $sku_lock ); 144 261 } 145 262 263 // SAVED from here on. A throw below must not report the copy as failed: it exists, 264 // and a "failed" copy invites a retry that duplicates it again. It stays in 265 // created_ids and is counted into the "unfinished" warning instead. 266 267 // Read back, not assumed: a before-save listener may have set a SKU of its own. 268 if ( ! $sku_derived && '' === $duplicate->get_sku( 'edit' ) ) { 269 $sku_blanked_products++; 270 } 271 146 272 try { 147 $duplicate->save(); 148 273 // Duplicate variations for variable products — unless an integration opts 274 // out through core's own filter, which core's Duplicate honours too. 275 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate filter. Not a hook we own. 276 if ( $source->is_type( 'variable' ) && ! apply_filters( 'woocommerce_duplicate_product_exclude_children', false, $source ) ) { 277 $variations = $this->duplicate_variations( $source, $duplicate, $meta_to_exclude, $sku_cursor, $lock_timeouts ); 278 $total_var_failures += $variations['failed']; 279 $sku_blanked_variations += $variations['sku_blanked']; 280 // As in core: listeners get the copy re-read with its variations in place. 281 $duplicate = wc_get_product( $duplicate->get_id() ) ?: $duplicate; 282 } 283 284 // Fired once the copy is complete — after its variations, as core's admin 285 // Duplicate does — so listeners (SEO meta copiers, translation plugins 286 // duplicating the copy) see its children. 149 287 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally firing WooCommerce's native `woocommerce_product_duplicate` action so third-party plugins listening for WC product duplications (SEO meta copiers, etc.) hook in. Not a hook we own. 150 288 do_action( 'woocommerce_product_duplicate', $duplicate, $source ); 151 152 // Duplicate variations for variable products. 153 if ( $source->is_type( 'variable' ) ) { 154 $total_var_failures += $this->duplicate_variations( $source, $duplicate ); 155 } 156 157 // Re-read from DB to get full data (variation_count, etc.). Formatting is 158 // deferred until every duplicate exists, so linked-product descriptors 159 // are resolved in one batch rather than per copy — a clone carries the 160 // source's upsells/cross-sells, so every duplicate has refs to resolve. 289 } catch ( \Throwable $e ) { 290 $unfinished++; 291 } 292 293 // Re-read from DB to get full data (variation_count, etc.) — deliberately a second 294 // read for a variable copy: it runs AFTER the listeners, so their changes reach the 295 // grid. Formatting is deferred until every duplicate exists, so linked-product 296 // descriptors are resolved in one batch rather than per copy — a clone carries the 297 // source's upsells/cross-sells, so every duplicate has refs to resolve. A re-read 298 // that throws keeps the saved object, so the copy is never dropped from 299 // created_ids; one that finds nothing (a listener deleted the copy) drops it. 300 try { 161 301 $fresh = wc_get_product( $duplicate->get_id() ); 162 if ( $fresh ) { 163 $fresh_products[] = $fresh; 164 } 165 } catch ( \Exception $e ) { 166 $failed[] = [ 167 'id' => $id, 168 'message' => sprintf( 169 /* translators: 1: copy index, 2: total copies */ 170 __( 'Failed to duplicate product (copy %1$d of %2$d).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 171 $i, 172 $count 173 ), 174 ]; 175 continue; 302 } catch ( \Throwable $e ) { 303 $fresh = $duplicate; 304 } 305 if ( $fresh ) { 306 $fresh_products[] = $fresh; 176 307 } 177 308 } … … 240 371 } 241 372 373 if ( $unfinished > 0 ) { 374 $warnings[] = sprintf( 375 /* translators: %d: number of duplicates that were saved but not finished */ 376 __( '%d duplicate(s) were created, but an error after saving (for example in another plugin) left them unfinished. Check them before duplicating again.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 377 $unfinished 378 ); 379 } 380 381 if ( $sku_blanked_products > 0 || $sku_blanked_variations > 0 ) { 382 // A copy that vanishes from a SKU-filtered grid must not do so silently (#252). 383 $warnings[] = self::sku_blank_warning( $sku_blanked_products, $sku_blanked_variations ); 384 } 385 242 386 if ( $format_failures > 0 ) { 243 387 // Appended, never replacing — a run can hit both a variation failure and a format … … 258 402 259 403 /** 404 * The warning for copies left without a SKU, naming products and variations apart: the 405 * duplicate toast reads "Duplicated N products." and counts top-level copies only, so one 406 * total over both would read as more SKU-less copies than products duplicated. 407 * 408 * @param int $products Top-level copies left without a SKU. 409 * @param int $variations Variation copies left without a SKU. 410 * @return string 411 */ 412 private static function sku_blank_warning( int $products, int $variations ): string { 413 if ( 0 === $variations ) { 414 return sprintf( 415 /* translators: %d: number of duplicated products left without a SKU */ 416 __( '%d duplicated product(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 417 $products 418 ); 419 } 420 421 if ( 0 === $products ) { 422 return sprintf( 423 /* translators: %d: number of duplicated variations left without a SKU */ 424 __( '%d duplicated variation(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 425 $variations 426 ); 427 } 428 429 return sprintf( 430 /* translators: 1: number of duplicated products left without a SKU, 2: number of duplicated variations left without a SKU */ 431 __( '%1$d duplicated product(s) and %2$d variation(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 432 $products, 433 $variations 434 ); 435 } 436 437 /** 260 438 * Duplicate all variations from source to new parent. 261 439 * 262 * Public so per-source duplication loops can invoke it independently of 263 * the higher-level variable-product duplicate flow. Not part of the 264 * stable extension surface; callers should treat this as internal. 265 * 266 * @param \WC_Product $source Original variable product. 267 * @param \WC_Product $new_parent Newly created duplicate. 268 * @return int Number of failed variation duplications. 269 */ 270 public function duplicate_variations( \WC_Product $source, \WC_Product $new_parent ): int { 271 $children = $source->get_children(); 272 $failed = 0; 273 274 foreach ( $children as $child_id ) { 440 * Private: its one caller is duplicate_products(). It was public for Pro's per-source 441 * duplicate loop, which has since moved into Free; the SKU cursor it shares with every copy 442 * of the call is an implementation detail, not an extension surface. 443 * 444 * @param \WC_Product $source Original variable product. 445 * @param \WC_Product $new_parent Newly created duplicate. 446 * @param array $meta_to_exclude The PARENT's excluded meta keys — see 447 * meta_keys_excluded_from_duplicate(). 448 * @param array $sku_cursor Per-call SKU suffix cursor, shared by every source and 449 * copy of the call — see assign_duplicate_sku(). 450 * @param array $lock_timeouts Per-call set of SKU lock names that timed out — see 451 * acquire_sku_lock(). 452 * @return array{failed: int, sku_blanked: int} Variation copies that failed, and saved 453 * copies of a SKU'd variation left without a SKU. 454 */ 455 private function duplicate_variations( \WC_Product $source, \WC_Product $new_parent, array $meta_to_exclude, array &$sku_cursor, array &$lock_timeouts ): array { 456 global $wpdb; 457 458 $sku_blanked = 0; 459 460 $children = []; 461 foreach ( $source->get_children() as $child_id ) { 275 462 $child = wc_get_product( $child_id ); 276 if ( ! $child || ! $child->is_type( 'variation' ) ) { 277 continue; 278 } 279 463 if ( $child && $child->is_type( 'variation' ) ) { 464 $children[] = $child; 465 } 466 } 467 $failed = 0; 468 469 // One batched suffix scan for every variation SKU not seeded yet in this call: the first 470 // copy pays it, later copies find every key seeded. 'edit' context — see below. The parent 471 // copy is already saved, so a throw here (a third-party `query` filter) must not fail it: 472 // every key left unseeded is seeded per copy by assign_duplicate_sku(), under its guard. 473 try { 474 $this->seed_sku_cursor( 475 $sku_cursor, 476 array_map( static fn( \WC_Product $child ): string => $child->get_sku( 'edit' ), $children ) 477 ); 478 } catch ( \Throwable $e ) { 479 // Fall back to the per-copy seed — see above. 480 } 481 482 // Slugs — see next_variation_slug(). One batched scan per copy, not per call: it reads 483 // the previous copy's saved variations, so no cursor has to outlive this method. A throw 484 // keeps the clones' slugs, which wp_unique_post_slug() still makes unique, only slower. 485 $slug_roots = array_map( static fn( \WC_Product $child ): string => self::variation_slug_root( $child->get_slug( 'edit' ) ), $children ); 486 $slug_cursor = []; 487 try { 488 $slug_cursor = $this->max_numeric_suffixes( $wpdb->posts, 'post_name', "post_type = 'product_variation'", $slug_roots ); 489 } catch ( \Throwable $e ) { 490 // Nothing seeded: every copy keeps its clone's slug — see above. 491 } 492 493 foreach ( $children as $i => $child ) { 280 494 $new_variation = clone $child; 281 495 $new_variation->set_id( 0 ); 282 496 $new_variation->set_parent_id( $new_parent->get_id() ); 283 $new_variation->set_sku( '' ); 284 $new_variation->set_date_created( null ); 497 $slug = self::next_variation_slug( $slug_roots[ $i ], $slug_cursor ); 498 if ( null !== $slug ) { 499 $new_variation->set_slug( $slug ); 500 } 501 // 'edit', not the default 'view': a variation's view-context SKU falls back to its 502 // PARENT's SKU, which would hand a SKU-less variation a derivative of the parent's. 503 $child_sku = $child->get_sku( 'edit' ); 504 // Held from the SKU checks through the save — see acquire_sku_lock(). 505 $sku_lock = self::acquire_sku_lock( $child_sku, $lock_timeouts ); 285 506 try { 286 $new_variation->save(); 287 } catch ( \Exception $e ) { 288 $failed++; 289 continue; 290 } 291 } 292 293 return $failed; 507 $sku_derived = $this->assign_duplicate_sku( $new_variation, $child_sku, $sku_cursor ); 508 $new_variation->set_global_unique_id( '' ); 509 $new_variation->set_date_created( null ); 510 // As in core: the PARENT's exclusion list, then the before-save action per 511 // variation. \Throwable for the same reason as duplicate_products()' per-copy 512 // catch: the parent copy is already saved, and an escaping Error would report it 513 // as failed. 514 try { 515 foreach ( $meta_to_exclude as $meta_key ) { 516 $new_variation->delete_meta_data( $meta_key ); 517 } 518 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate hook, fired so listeners treat this copy like a core duplicate. Not a hook we own. 519 do_action( 'woocommerce_product_duplicate_before_save', $new_variation, $child ); 520 521 $new_variation->save(); 522 if ( ! $sku_derived && '' === $new_variation->get_sku( 'edit' ) ) { 523 $sku_blanked++; 524 } 525 } catch ( \Throwable $e ) { 526 $failed++; 527 } 528 } finally { 529 self::release_sku_lock( $sku_lock ); 530 } 531 } 532 533 return [ 534 'failed' => $failed, 535 'sku_blanked' => $sku_blanked, 536 ]; 537 } 538 539 /** 540 * Take a MySQL named lock on a copy's source SKU for the span from assign_duplicate_sku() 541 * through the copy's save. Returns the lock name, or null when no lock is held. 542 * 543 * Two concurrent duplicates of the same product derive the same candidates: both would pass 544 * the checks for `V-RED-1` and both save it. Variation creates take no SKU lock in WC, and 545 * product create() takes one only when WC()->is_rest_api_request() holds and 546 * `wc_product_pre_lock_on_sku` does not short-circuit it — so parent and variation copies 547 * both take this one. Candidates are `<source SKU>-<n>`, and only equal source SKUs derive 548 * equal candidates (see assign_duplicate_sku()), so one lock per source SKU serialises exactly 549 * the copies that can collide: the second waits, then its checks see the first one's saved 550 * row and it walks on. Case-folded, as SKU uniqueness is case-insensitive under the column 551 * collation; accent-equal source SKUs (dirty data — WC's uniqueness check forbids them) take 552 * different locks. The lock is held while before-save and save listeners run, so a slow 553 * listener makes a concurrent duplicate wait longer. 554 * 555 * Best effort, never a failure: on a timeout, or a database without GET_LOCK() (a SQLite 556 * drop-in, some clusters), the copy goes ahead unlocked — the checks still run, only the 557 * concurrent race stays open. A name that timed out, or a GET_LOCK() that failed, is recorded 558 * in `$timed_out` and not waited on again in this call: a lock held by a stuck request would 559 * otherwise cost SKU_LOCK_TIMEOUT per copy, enough to push a "Duplicate X times" run past 560 * max_execution_time partway through. MySQL releases a dead request's locks with its 561 * connection. The name is scoped by the lookup table, so sites sharing a database server do 562 * not contend. 563 * 564 * @param string $source_sku The source's SKU in 'edit' context. 565 * @param array $timed_out Per-call set of lock names not to wait on again. 566 * @return string|null 567 */ 568 private static function acquire_sku_lock( string $source_sku, array &$timed_out ): ?string { 569 global $wpdb; 570 571 if ( '' === $source_sku ) { 572 return null; 573 } 574 575 $name = static::sku_lock_name( $source_sku ); 576 if ( isset( $timed_out[ $name ] ) ) { 577 return null; 578 } 579 580 try { 581 // Through run_chunk_query(): a failed GET_LOCK() must read as "not held", never as the 582 // previous query's stale '1'. 583 $rows = Product_Service::run_chunk_query( 584 $wpdb->prepare( 'SELECT GET_LOCK( %s, %d ) AS held', $name, static::SKU_LOCK_TIMEOUT ), 585 'duplicate SKU lock unavailable; the copy goes ahead unlocked.' 586 ); 587 } catch ( \Throwable $e ) { 588 // A third-party `query` filter threw: unlocked, as above. 589 $rows = null; 590 } 591 592 if ( isset( $rows[0]->held ) && '1' === (string) $rows[0]->held ) { 593 return $name; 594 } 595 596 $timed_out[ $name ] = true; 597 return null; 598 } 599 600 /** 601 * Release a lock taken by acquire_sku_lock(). 602 * 603 * @param string|null $name Lock name, or null when none was taken. 604 * @return void 605 */ 606 private static function release_sku_lock( ?string $name ): void { 607 global $wpdb; 608 609 if ( null === $name ) { 610 return; 611 } 612 613 try { 614 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see acquire_sku_lock(). 615 $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', $name ) ); 616 } catch ( \Throwable $e ) { 617 // Runs in a `finally`: a throw here would replace the loop's outcome. The lock goes 618 // with the connection at the end of the request. 619 } 620 } 621 622 /** 623 * Named-lock name for a source SKU: 45 characters, within MySQL's 64. Protected so a test can 624 * hold the same lock from a second connection. 625 * 626 * @param string $source_sku Source SKU. 627 * @return string 628 */ 629 protected static function sku_lock_name( string $source_sku ): string { 630 global $wpdb; 631 632 return 'wcabe_g2_sku_' . md5( $wpdb->wc_product_meta_lookup . '|' . mb_strtolower( $source_sku ) ); 633 } 634 635 /** 636 * Meta keys a duplicate of `$source` must not inherit, from the 637 * `woocommerce_duplicate_product_exclude_meta` filter of WC core's own Duplicate — 638 * integrations list their external IDs there (catalogue / POS sync IDs and the like) so a 639 * copy is not linked to the original's remote item. Applied to the copy and, as in core, to 640 * each of its variations. 641 * 642 * @param \WC_Product $source Product being duplicated. 643 * @return array 644 */ 645 private static function meta_keys_excluded_from_duplicate( \WC_Product $source ): array { 646 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate filter, applied so integrations' exclusions hold for this copy too. Not a hook we own. 647 $excluded = apply_filters( 648 'woocommerce_duplicate_product_exclude_meta', 649 [], 650 array_map( static fn( \WC_Meta_Data $meta ) => $meta->key, $source->get_meta_data() ) 651 ); 652 // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 653 654 return array_filter( (array) $excluded ); 655 } 656 657 /** 658 * Give a duplicate a unique SKU derived from its source: `ABC` → `ABC-1`, `ABC-2`, … 659 * 660 * Mirrors WooCommerce core's own Duplicate (WC 10.5+ `WC_Admin_Duplicate_Product:: 661 * generate_unique_sku()`, which is private/@internal), so a copy stays in a SKU-filtered 662 * grid ("contains" / "starts with") instead of vanishing on the post-duplicate refetch. 663 * A source without a SKU gives a copy without one, as in core. 664 * 665 * `$cursor` maps a source SKU to the highest suffix handed out or probed. It is seeded once 666 * per distinct SKU by seed_sku_cursor() — here lazily for a parent's SKU, in one batch per 667 * source by duplicate_variations() for its variations' — and advanced by every candidate 668 * tried, so the Nth copy of a call never rescans and never re-probes a candidate an earlier 669 * copy rejected. Each 670 * candidate, from the next suffix up, must pass three checks — normally the first one tried 671 * does: 672 * 673 * 1. It fits the lookup table's `sku` column (fits_lookup_sku_column()). A longer SKU makes 674 * wpdb reject the copy's lookup row, and under REST WC's lock INSERT truncates it — for a 675 * 100-char source, into the source's own SKU. Walking on only lengthens the suffix, so the 676 * copy gets a blank SKU instead. 677 * 2. No lookup row holds it (lookup_holds_sku()) — WC's create-time SKU lock's own predicate, 678 * trash and collation-equal holders included. The suffix scan misreads some of those (a 679 * trailing space under a PAD SPACE collation, `ß` vs `ss` under an expansion collation) 680 * and set_sku()'s uniqueness check skips trash; without this check such a holder fails the 681 * copy at create() on every attempt, and a variation (no lock) silently takes a 682 * collation-duplicate of it. 683 * 3. set_sku() accepts it. Its wc_product_has_unique_sku() check is the ONLY uniqueness check 684 * (no separate probe repeating it) and runs the uniqueness filters 685 * (`wc_product_pre_has_unique_sku` / `wc_product_has_unique_sku`), e.g. WCML's. A rejection 686 * — a filter claim, or a writer that took the candidate since check 2 — walks on to the 687 * next suffix. On rejection set_sku() also asks WC for a suggestion 688 * (wc_product_generate_unique_sku() from the candidate), which recurses without bound only 689 * under a filter that rejects every string — one that would already crash WC's own product 690 * editor on any rejected SKU. 691 * 692 * A copy is never failed over its SKU: when no candidate passes (too long, MAX_SKU_PROBES 693 * rejected by set_sku(), or MAX_HELD_SKU_PROBES held), or third-party code on the SKU path 694 * throws, it gets a blank SKU and this returns false, which duplicate_products() reports as a 695 * warning. Concurrent duplicates of the same source are serialised by the callers: both hold 696 * a named lock per source SKU from this call through the save (acquire_sku_lock()), so the 697 * second waits and then walks past the first one's saved copy. WC's own create-time SKU lock 698 * (product create(), REST requests only, filterable) is not relied on. If it still fires — a 699 * writer outside G2, or the named lock unavailable — WC deletes the half-created post and 700 * throws a plain Exception, which duplicate_products() reports as a failed copy (truthful — 701 * nothing is orphaned). Recovery is deliberately not attempted: it would re-run create() 702 * after WC's delete( true ) fired its delete hooks, and would have to recognise the lock 703 * failure by message text. Check 2 reads exactly what that lock reads, so it can only fail on 704 * a genuine race. 705 * 706 * @param \WC_Product $copy The unsaved duplicate (its ID must already be 0). 707 * @param string $source_sku The source's SKU in 'edit' context. 708 * @param array $cursor Per-call map of source SKU → highest suffix handed out or probed. 709 * @return bool False when the source has a SKU but the copy was left without one. 710 */ 711 private function assign_duplicate_sku( \WC_Product $copy, string $source_sku, array &$cursor ): bool { 712 if ( '' === $source_sku ) { 713 $copy->set_sku( '' ); 714 return true; 715 } 716 717 try { 718 if ( ! isset( $cursor[ $source_sku ] ) ) { 719 $this->seed_sku_cursor( $cursor, [ $source_sku ] ); 720 } 721 722 // Two budgets: a held candidate is a real row, so walking past it is progress; a 723 // rejection may be a filter that rejects everything. 724 $rejected = 0; 725 $held = 0; 726 while ( $rejected < self::MAX_SKU_PROBES && $held < static::MAX_HELD_SKU_PROBES ) { 727 $candidate = $source_sku . '-' . ( ++$cursor[ $source_sku ] ); 728 729 if ( ! self::fits_lookup_sku_column( $candidate ) ) { 730 break; 731 } 732 733 if ( self::lookup_holds_sku( $candidate ) ) { 734 $held++; 735 continue; 736 } 737 738 try { 739 $copy->set_sku( $candidate ); 740 return true; 741 } catch ( \WC_Data_Exception $e ) { 742 $rejected++; 743 } 744 } 745 } catch ( \Throwable $e ) { 746 // Third-party code on the SKU path threw — a uniqueness filter inside set_sku(), or a 747 // `query` / `pre_get_col_charset` filter under the checks. This runs outside the 748 // callers' per-copy try, after earlier copies were saved: an escape would 500 the 749 // request (and a retry would duplicate those copies again) or report a saved parent 750 // as failed. Give up on the SKU rather than walk on — the next candidate would run 751 // the same code. 752 } 753 754 // Blank it explicitly: the clone still carries the source's SKU. 755 $copy->set_sku( '' ); 756 return false; 757 } 758 759 /** 760 * Whether `$sku` can be stored in `wc_product_meta_lookup.sku` unchanged. 761 * 762 * Asks wpdb the same question its insert/replace helpers ask before writing (process_fields() 763 * → strip_invalid_text()): character-based on utf8mb4, and it honours a column a host has 764 * altered. A WP_Error (column metadata unreadable) counts as "does not fit". 765 * 766 * @param string $sku Candidate SKU. 767 * @return bool 768 */ 769 private static function fits_lookup_sku_column( string $sku ): bool { 770 global $wpdb; 771 772 return $wpdb->strip_invalid_text_for_column( $wpdb->wc_product_meta_lookup, 'sku', $sku ) === $sku; 773 } 774 775 /** 776 * Whether any lookup row holds `$sku` under the column collation, trash included. 777 * 778 * Direct SQL — the documented CORRECTNESS exception to the WC-CRUD rule (CLAUDE.md, PHP 779 * Backend #3): this is the predicate of WC's create-time SKU lock (`WC_Product_Data_Store_CPT:: 780 * obtain_lock_on_sku_for_concurrent_requests()`, itself raw SQL, same unslashed value), and 781 * no CRUD API reads "any lookup row". A failed query reads as "not held" — never a stale row 782 * (Product_Service::run_chunk_query()) — and set_sku()'s check and the lock still guard the 783 * save. 784 * 785 * @param string $sku Candidate SKU. 786 * @return bool 787 */ 788 private static function lookup_holds_sku( string $sku ): bool { 789 global $wpdb; 790 791 $rows = Product_Service::run_chunk_query( 792 $wpdb->prepare( 793 "SELECT 1 AS held FROM {$wpdb->wc_product_meta_lookup} WHERE sku = %s LIMIT 1", 794 $sku 795 ), 796 'duplicate SKU holder check failed; relying on set_sku() and the create-time lock.' 797 ); 798 799 return ! empty( $rows ); 800 } 801 802 /** 803 * Seed `$cursor` with the highest purely numeric `-N` suffix already used on each SKU in 804 * `$skus` (0 when none): `ABC-7` over `ABC` → 7. 805 * 806 * Direct SQL (max_numeric_suffixes()) — a documented, measured performance exception to the 807 * WC-CRUD rule (numbers below). wc_product_generate_unique_sku() alone probes one index at a 808 * time from the start, which goes quadratic across "Duplicate X times" (up to 809 * MAX_COPIES_PER_PRODUCT) and repeated duplication of the same product; WC core replaced it 810 * with a single suffix query in 10.5 for that reason. This goes one step further on both 811 * axes: MAX is computed in SQL, so a short base in an `A-00001…` catalogue returns one row 812 * instead of every match; and every unseeded SKU of `$skus` is scanned in one statement per 813 * SUFFIX_SCAN_CHUNK, so a variable product's variations cost one statement, not one per 814 * variation SKU. Empty and already-seeded SKUs are skipped. 815 * 816 * Measured (MySQL 8.0.35 over a local socket, the PHPUnit test DB, 2026-09-24; median of 5 817 * runs, SKU work only — the saves are excluded): 818 * - 50 copies of a SKU that already has 200 `-N` holders: per-index probing from 1 takes 819 * 11,276 queries / 589 ms; this scan plus the per-copy checks takes 101 queries / 4.6 ms. 820 * - A prefix with 20,000 matching lookup rows: fetching them all for a PHP MAX takes 11.5 ms 821 * and 20,000 rows of PHP memory; the SQL MAX takes 5.7 ms and returns one row. 822 * - 500 distinct SKUs: one statement each takes 500 queries / 16.1 ms; batched, 5 / 8.8 ms — 823 * and every avoided round trip costs more against a networked database. 824 * 825 * Suffix rules: see max_numeric_suffixes(). No status filter: a trashed product's SKU counts, 826 * so restoring it can never collide (WC's create-time SKU lock reads the same rows). The prefix match is case-insensitive through 827 * the column collation, like WC's own uniqueness check, and the suffix is cut by CHARACTER 828 * length, so an accent variant (`PÀ-7` over `PA`) reads as 7. 829 * 830 * The result is only where the walk starts. Holders the scan still misreads (a trailing 831 * space under a PAD SPACE collation, an expansion collation's `ß` vs `ss`) are caught by 832 * assign_duplicate_sku()'s per-candidate lock-predicate check, and a failed statement seeds 833 * 0, after which the walk steps past every existing holder — held candidates do not count 834 * toward MAX_SKU_PROBES. Either way it costs extra queries, never a wrong or missing SKU 835 * (short of MAX_HELD_SKU_PROBES holders). 836 * 837 * @param array $cursor Per-call map of source SKU → highest suffix handed out or probed. 838 * @param array $skus SKUs to seed. 839 * @return void 840 */ 841 private function seed_sku_cursor( array &$cursor, array $skus ): void { 842 global $wpdb; 843 844 $pending = array_filter( 845 $skus, 846 static fn( $sku ): bool => ! isset( $cursor[ (string) $sku ] ) 847 ); 848 849 foreach ( $this->max_numeric_suffixes( $wpdb->wc_product_meta_lookup, 'sku', '', $pending ) as $sku => $max ) { 850 $cursor[ $sku ] = $max; 851 } 852 } 853 854 /** 855 * The slug for the next copy of a variation whose slug has root `$root` (see 856 * variation_slug_root()): `root-(N+1)`, N the highest suffix handed out or already taken 857 * under that root — or null to keep the clone's slug (no root, or no scan result). 858 * 859 * A clone keeps its source's slug, which wp_unique_post_slug() then walks from `-2`, one 860 * query per taken suffix. Every earlier copy — in this call or any before — adds a step for 861 * every variation, so "Duplicate X times" went quadratic, and a variation with 3+ attributes 862 * (whose title, and so slug, is the parent's) walked past its own siblings too. WC core 863 * computes the suffix up front for the same reason (`WC_Admin_Duplicate_Product:: 864 * generate_unique_slug()`, private). The handed-out slug is normally free, so the insert 865 * pays one check; when it is not (a concurrent writer, a failed scan, a root near the 866 * 200-character `post_name` limit, which sanitize_title() truncates), 867 * wp_unique_post_slug() still walks — slower, never a duplicate slug. 868 * 869 * The scan is direct SQL on `wp_posts` (max_numeric_suffixes()) — a documented, measured 870 * performance exception to the CRUD rule: no API reads "the highest suffix in use". Scoped 871 * like the check it replaces, `post_type = 'product_variation'` and no status filter. 872 * 873 * Measured (MySQL 8.0.35 over a local socket, the PHPUnit test DB, 2026-09-24; median of 3 874 * runs of one duplicate_products() call making 50 copies of a published 20-variation 875 * product, saves included — slug checks / all queries / wall time): 876 * - 1 attribute: 26,500 / 165,557 / 8.0 s before; 1,000 (+50 scans) / 140,103 / 7.1 s after. 877 * - 3 attributes (every variation shares the parent's slug): 27,450 / 169,513 / 9.2 s 878 * before; 1,000 (+50 scans) / 143,103 / 7.7 s after. 879 * The saves dominate the wall time on a local socket; the ~25,000 avoided round trips weigh 880 * more against a networked database, and before, every EARLIER duplicate of the product 881 * lengthened each walk too. 882 * 883 * @param string $root Slug root, '' for none. 884 * @param array $cursor Per-copy map of slug root → highest suffix handed out or taken. 885 * @return string|null 886 */ 887 private static function next_variation_slug( string $root, array &$cursor ): ?string { 888 if ( '' === $root || ! isset( $cursor[ $root ] ) ) { 889 return null; 890 } 891 892 return $root . '-' . ( ++$cursor[ $root ] ); 893 } 894 895 /** 896 * A variation slug without its trailing `-N`, so copies of copies share their source's root 897 * instead of nesting (`shirt-red-3` → `shirt-red`), as in WC core. 898 * 899 * @param string $slug Slug in 'edit' context. 900 * @return string 901 */ 902 private static function variation_slug_root( string $slug ): string { 903 return (string) preg_replace( '/-[0-9]+$/', '', $slug ); 904 } 905 906 /** 907 * The highest purely numeric `-N` suffix already used on each base in `$bases`, keyed by base 908 * (0 when none; empty bases skipped): `ABC-7` over `ABC` → 7. Every row of `$table` within 909 * `$scope` counts, whatever its status. 910 * 911 * One statement per SUFFIX_SCAN_CHUNK bases — a `UNION ALL` of constant-pattern branches, 912 * each keeping its index range scan on `$column` and returning exactly one row, the MAX 913 * computed in SQL. Suffix rules: digits only — stricter than core's is_numeric(): `1e3`, 914 * `1.5` and `1-2` are not suffixes — and at most 18 of them, so the cast fits BIGINT and the 915 * PHP int. The prefix match follows the column collation and the suffix is cut by CHARACTER 916 * length. A failed statement reads as 0 for its bases. Callers document why their scan is a 917 * measured performance exception to the CRUD rule. 918 * 919 * @param string $table Table name — a $wpdb property, never input. 920 * @param string $column Column to scan — a fixed identifier, never input. 921 * @param string $scope Fixed SQL predicate ANDed into every branch, or '' — never input. 922 * @param array $bases Bases to scan. 923 * @return array<int|string, int> Base → highest suffix (numeric-string bases come back as int keys). 924 */ 925 private function max_numeric_suffixes( string $table, string $column, string $scope, array $bases ): array { 926 global $wpdb; 927 928 $pending = []; 929 foreach ( $bases as $base ) { 930 $base = (string) $base; 931 if ( '' !== $base ) { 932 $pending[ $base ] = true; 933 } 934 } 935 936 $where = '' !== $scope ? "{$scope} AND " : ''; 937 $max = []; 938 939 // array_keys() hands numeric-string bases back as ints — cast them back. 940 foreach ( array_chunk( array_map( 'strval', array_keys( $pending ) ), static::SUFFIX_SCAN_CHUNK ) as $chunk ) { 941 $branches = []; 942 $args = []; 943 foreach ( $chunk as $k => $base ) { 944 $branches[] = 'SELECT ' . (int) $k . " AS k, MAX( CAST( SUBSTRING( {$column}, CHAR_LENGTH( %s ) + 2 ) AS UNSIGNED ) ) AS m" 945 . " FROM {$table}" 946 . " WHERE {$where}{$column} LIKE %s AND SUBSTRING( {$column}, CHAR_LENGTH( %s ) + 2 ) REGEXP '^[0-9]{1,18}\$'"; 947 array_push( $args, $base, $wpdb->esc_like( $base ) . '-%', $base ); 948 } 949 950 // Through run_chunk_query(): a failed statement must read as "no rows", never as the 951 // previous query's stale rows (wpdb leaves those in place when a `query` filter 952 // empties the SQL). 953 $rows = Product_Service::run_chunk_query( 954 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- documented, measured performance exception (see the callers' docblocks). The statement is fixed SQL plus the int branch index and the caller's fixed table, column and scope; every base goes through prepare()'s three %s per branch. 955 $wpdb->prepare( implode( ' UNION ALL ', $branches ), $args ), 956 'duplicate suffix scan failed; its bases start at 0 and the walk steps past each holder.' 957 ); 958 959 $max_by_branch = []; 960 foreach ( (array) $rows as $row ) { 961 // MAX over no rows is NULL → 0. 962 $max_by_branch[ (int) $row->k ] = (int) $row->m; 963 } 964 foreach ( $chunk as $k => $base ) { 965 $max[ $base ] = $max_by_branch[ $k ] ?? 0; 966 } 967 } 968 969 return $max; 294 970 } 295 971 -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Product_Service.php
r3700717 r3736631 9 9 10 10 use WCABE_G2\Handlers\WP_Save_Hooks_Handler; 11 use WCABE_G2\Helpers\Html_Loss_Detector; 11 12 use WCABE_G2\Helpers\Product_Helper; 13 use WCABE_G2\Helpers\Term_Helper; 12 14 13 15 defined( 'ABSPATH' ) || exit; … … 17 19 /** Allowed Title-search match modes. */ 18 20 public const TITLE_SEARCH_MODES = [ 'phrase', 'all', 'any' ]; 21 22 /** Allowed Category filter modes (mirrors CategoryOperator in TS). 'in' is the default. */ 23 public const CATEGORY_OPERATORS = [ 'in', 'not_in' ]; 19 24 20 25 /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */ … … 133 138 134 139 /** 140 * A byte that survives sanitize_title_with_dashes()'s post-cap cleanup when 141 * it sits in the tail the SLUG_MAX_BYTES cap cuts off (#216). 142 * 143 * The capped encoding is a character-boundary prefix of the uncapped one, 144 * so the cap changes the slug only when that tail would have contributed to 145 * it. A tail with none of these bytes is removed in full by the post-cap 146 * stages anyway, in either context: 147 * - no `;`: no `&…;` entity removal (nor a ` `-style 'save' mapping) 148 * can match inside it or across the cut; 149 * - no `%`: no 'save'-context octet mapping or strip touches it; 150 * - everything else (`!`, `.`, `/`, whitespace, `-`, …) is stripped by 151 * `[^%a-z0-9 _-]`, or is a space / becomes a `-`, and a trailing run of 152 * those is collapsed and trimmed. 153 * Letters (either case), digits and `_` are kept. Conservative by design: 154 * a non-ASCII character that only the 'save' context strips (curly quote, 155 * soft hyphen, ZWSP…), or an entity lying wholly in the tail, still counts — 156 * exempting them would need a hand-kept copy of core's 'save' list. 157 * 158 * Mirrored by SLUG_KEPT_TAIL in validators.ts (validateSlugLength()) — 159 * keep both in step. 160 */ 161 private const SLUG_KEPT_TAIL_PATTERN = '/[A-Za-z0-9_%;]/'; 162 163 /** 135 164 * The installed core's own UTF-8 validity check. 136 165 * … … 154 183 155 184 /** 156 * Whether sanitize_title() would silently cut this slug at SLUG_MAX_BYTES. 185 * Whether sanitize_title()'s SLUG_MAX_BYTES cap would silently change this 186 * slug — store something other than what the same chain stores uncapped. 187 * 188 * Being over 200 bytes before the cap is not enough (#216): the cleanup that 189 * runs after the cap strips, collapses and trims, so `'a' × 200 . ' '` or 190 * `'a' × 150 . '!' × 60` is cut and still stored exactly as an uncapped core 191 * would store it. Comparing the CLEANED uncapped length with 200 is not the 192 * test either — `'a' × 199 . '!!b'` cleans to 200 bytes, yet the cap loses 193 * the `b`, and `'a' × 196 . '&'` splits the entity and stores `…amp`. 194 * What decides it is whether the cut-off tail holds a byte the cleanup would 195 * have kept (SLUG_KEPT_TAIL_PATTERN). 157 196 * 158 197 * Pure predicate. There is no way to ask WordPress: sanitize_title() has no … … 180 219 * so a late registration runs last among the <= 9 callbacks — immediately 181 220 * before sanitize_title_with_dashes()), calls sanitize_title() exactly as the 182 * save will, and has the observer measure the UNCAPPED encoding of the221 * save will, and has the observer test the tail the cap cuts off the 183 222 * string it receives. The last invocation wins: a re-entrant filter like 184 223 * WPML's fires the observer again with the expanded title, and that nested … … 188 227 * stages — strip_tags(), the percent-octet dance (which also rewrites 189 228 * literal ASCII `---XX---` to `%XX`), the UTF-8 validity gate, the 190 * mbstring-guarded lowercase, then utf8_uri_encode() with no length. Those 191 * have been byte-identical since WP 2.x (bar seems_utf8 → wp_is_valid_utf8, 192 * handled by is_valid_utf8_compat()); everything upstream of them — 193 * remove_accents() with its locale rules, NFC normalisation and Latin-1 194 * repair, plus every third-party callback — is core's own work, observed 195 * rather than re-derived. Keep the observer's stages in order and verbatim. 229 * mbstring-guarded lowercase, then utf8_uri_encode() with and without the 230 * cap. Those have been byte-identical since WP 2.x (bar seems_utf8 → 231 * wp_is_valid_utf8, handled by is_valid_utf8_compat()); everything upstream 232 * of them — remove_accents() with its locale rules, NFC normalisation and 233 * Latin-1 repair, plus every third-party callback — is core's own work, 234 * observed rather than re-derived. Keep the observer's stages in order and 235 * verbatim. 196 236 * 197 237 * Invalid UTF-8 mirrors core: no encoding, no cap, so never "too long" here. … … 205 245 $exceeds = false; 206 246 $observer = static function ( $title ) use ( &$exceeds ) { 207 $ length = self::uncapped_slug_bytes( (string) $title );208 $exceeds = null !== $ length && $length > self::SLUG_MAX_BYTES;247 $tail = self::slug_cap_cut_tail( (string) $title ); 248 $exceeds = null !== $tail && 1 === preg_match( self::SLUG_KEPT_TAIL_PATTERN, $tail ); 209 249 return $title; 210 250 }; … … 221 261 222 262 /** 223 * The encoded byte length sanitize_title_with_dashes() would measure this 224 * title at with its 200-byte cap removed, or null where core skips encoding 225 * (invalid UTF-8). $title is what the 'sanitize_title' filter chain hands to 226 * that callback — see slug_exceeds_wp_limit(). 263 * Sanitise description / short-description HTML the way WordPress's own 264 * editor does, and report what was removed (#248). 265 * 266 * KSES runs only for users WITHOUT `unfiltered_html`, mirroring core's 267 * kses_init_filters() and the WooCommerce product editor (multisite: super 268 * admins only; DISALLOW_UNFILTERED_HTML removes the capability). Running it 269 * for everyone silently stripped administrators' iframes and embeds on any 270 * edit. When it does run, Html_Loss_Detector lists every removed element, 271 * attribute, CSS declaration and rewritten URL so the loss is never silent. 272 * 273 * The single sanitiser behind both the save (apply_field_change()) and the 274 * dialog's pre-Apply preview (POST products/sanitize-html), so the preview 275 * shows exactly what the save will store. 276 * 277 * @param string $value Submitted HTML. 278 * @return array{value: string, losses: list<array{type: string, tag: string, attribute: string, detail: string}>} 279 */ 280 public static function sanitize_description_html( string $value ): array { 281 if ( current_user_can( 'unfiltered_html' ) ) { 282 return [ 283 'value' => $value, 284 'losses' => [], 285 ]; 286 } 287 288 $sanitized = wp_kses_post( $value ); 289 290 return [ 291 'value' => $sanitized, 292 'losses' => ( new Html_Loss_Detector() )->diff( $value, $sanitized ), 293 ]; 294 } 295 296 /** 297 * A submitted GTIN with its whitespace removed (#250). UPC / EAN codes are 298 * often printed in digit groups, and WC_Product::set_global_unique_id() 299 * strips the spaces anyway. The whitespace set (U+0009–U+000D, space, NBSP) 300 * is spelled as code points because `\s` / `\v` mean different sets in PCRE 301 * and JavaScript; normalizeGtin() in validators.ts mirrors it — keep both in 302 * step. 303 * 304 * Invalid UTF-8 makes the /u replace fail (null); the input is then returned 305 * unchanged so the format check rejects it, never read as '' (a clear). 306 * 307 * @param mixed $value Submitted value; null reads as ''. validate_changes() 308 * rejects non-scalars before the save reaches here. 309 * @return string 310 */ 311 private static function normalize_gtin( $value ): string { 312 $raw = is_scalar( $value ) ? (string) $value : ''; 313 $normalized = preg_replace( '/[\x{0009}-\x{000D}\x{0020}\x{00A0}]+/u', '', $raw ); 314 return null === $normalized ? $raw : $normalized; 315 } 316 317 /** 318 * Whether a normalised GTIN is in the form WooCommerce stores unchanged: 319 * digits and hyphens, with an X only as the final ISBN-10 check digit. 320 * set_global_unique_id() silently strips every other character (so "N/A" 321 * would wipe the stored GTIN) and throws when an X is not last; the save 322 * rejects both up front instead (#250). The pattern is WooCommerce's own 323 * format check and is mirrored by validateGtin() in validators.ts. 324 * 325 * @param string $gtin Normalised GTIN (see normalize_gtin()). 326 * @return bool 327 */ 328 private static function is_valid_gtin_format( string $gtin ): bool { 329 return 1 === preg_match( '/^[0-9\-]*[0-9Xx]?$/', $gtin ); 330 } 331 332 /** 333 * The part of this title's encoding that sanitize_title_with_dashes()'s 334 * 200-byte cap cuts off ('' when it fits), or null where core skips encoding 335 * (invalid UTF-8). utf8_uri_encode() with a length stops at a character 336 * boundary of the very string it builds without one, so the capped encoding 337 * is a prefix of the uncapped one. "Character" is the encoder's: a literal 338 * `%XX` octet the pre-cap stages preserved is three ASCII characters to it 339 * and can be split, which leaves a `%` or a hex digit in the tail — a kept 340 * byte, so SLUG_KEPT_TAIL_PATTERN rejects it. $title is what the 341 * 'sanitize_title' filter chain hands to that callback — see 342 * slug_exceeds_wp_limit(). 227 343 * 228 344 * @param string $title Title as received by the priority-10 callback. 229 * @return int|null230 */ 231 private static function uncapped_slug_bytes( string $title ): ?int{345 * @return string|null 346 */ 347 private static function slug_cap_cut_tail( string $title ): ?string { 232 348 $title = strip_tags( $title ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- mirroring sanitize_title_with_dashes() byte-for-byte. 233 349 // Preserve escaped octets, drop lone percent signs, restore octets. … … 243 359 } 244 360 245 return strlen( utf8_uri_encode( $title ) ); 361 $uncapped = utf8_uri_encode( $title ); 362 $capped = utf8_uri_encode( $title, self::SLUG_MAX_BYTES ); 363 364 return substr( $uncapped, strlen( $capped ) ); 246 365 } 247 366 … … 254 373 255 374 /** 256 * Private WP_Query marker scoping get_products()'s Product URL posts_join / posts_where 257 * callbacks to our own query. Same rationale and shape as REF_SEARCH_QUERY_VAR above — 258 * see the comment at those callbacks. 259 */ 260 private const PRODUCT_URL_QUERY_VAR = 'wcabe_product_url_filter'; 261 262 /** 263 * Private WP_Query marker scoping get_products()'s server-sort posts_clauses 264 * callback to our own query. Same rationale and shape as the two markers above — 265 * and more critical here, because the callback REPLACES the ORDER BY wholesale 266 * rather than appending a predicate. Doubles as a sentinel on the 267 * wc_get_products() args: the marker filter fires for EVERY Woo product query 268 * while registered, so it only stamps the WP_Query args when the originating 269 * query vars carry this sentinel — a nested wc_get_products() call started by a 270 * third-party listener during ours is left untouched. See build_sort_clauses(). 271 */ 272 private const SORT_QUERY_VAR = 'wcabe_sort'; 375 * Private WP_Query marker scoping EVERY posts_join / posts_where / posts_clauses 376 * callback that get_products_wc_crud() registers (stock quantity, search, description, 377 * short description, ID, the combined COGS / sale / image / manage-stock / backorders 378 * predicates, Product URL, and the server sort) to the ONE invocation that registered 379 * them. Same rationale as REF_SEARCH_QUERY_VAR above (those filters fire on every 380 * WP_Query alive while registered), and more critical for the sort callback, which 381 * REPLACES the ORDER BY wholesale rather than appending a predicate. 382 * 383 * The value is a per-invocation token, not a boolean: every callback compares it 384 * strictly against the token its own invocation minted, so a recursive service call 385 * started by a third-party listener (which mints its own token) never inherits the 386 * outer invocation's clauses. The token is derived from the nesting depth 387 * ($products_query_depth), NOT from uniqid(): a random value would land in 388 * WP_Query's posts-cache key and make every service query a guaranteed cache miss, 389 * while the depth is identical for identical requests (cache keys stay stable — the 390 * key also hashes the final SQL, so a stable token can never serve a differently 391 * filtered result) and still differs between an invocation and any service call 392 * nested inside its query. The token is set on the wc_get_products() args and 393 * reaches WP_Query through WC's unmapped-arg passthrough; a nested wc_get_products() 394 * call started by a third-party listener during ours does not carry it unless that 395 * listener copies the originating args wholesale (WC hands them to 396 * woocommerce_product_object_query_args and the data-store filter) — an accepted 397 * edge: such a clone asks for our query and gets it. See the registration block 398 * in get_products_wc_crud() for the mechanism. 399 */ 400 private const PRODUCTS_QUERY_VAR = 'wcabe_products_query'; 401 402 /** 403 * Number of get_products_wc_crud() invocations currently inside their 404 * wc_get_products() call. Incremented right before the query, decremented in its 405 * finally, and folded into the PRODUCTS_QUERY_VAR token (see there). Sibling 406 * sequential invocations share a depth — harmless, because every scoped callback 407 * is removed in the same finally before a sibling can register its own. 408 */ 409 private static int $products_query_depth = 0; 273 410 274 411 /** … … 284 421 private const QUERY_MODE_IDS = 'ids'; 285 422 private const QUERY_MODE_IDS_ALL_VARIABLE = 'ids_all_variable'; 423 424 /** 425 * Most matching _stock_status rows the stock-status pre-read (#276) may 426 * return before it is dropped for the per-row probes: it bounds the memory 427 * and the literal id lists. Only a COMMON status reaches it, and then the 428 * child EXISTS stops at its first match anyway. 429 */ 430 private const STOCK_STATUS_PREFILTER_CAP = 50000; 431 432 /** 433 * A plain decimal with at most nine integer digits, as a quoted SQL REGEXP 434 * literal — the only stored values the child numeric range tests compare; 435 * everything else is kept for PHP (sql_decimal_range_or_keep()). 436 */ 437 private const SQL_PLAIN_DECIMAL_REGEX = "'^-?[0-9]{1,9}([.][0-9]+)?$'"; 438 439 /** 440 * The plain decimals whose string truncation provably equals PHP's intval(): 441 * intval() parses through a double first, so a fraction within half an ulp of 442 * the next integer rounds up ('9.99999999999999999' → 10), while cutting the 443 * string at '.' gives 9. With at most nine integer digits the half-ulp is 444 * under 1e-7, and a fraction of at most six digits stays at least 1e-6 short 445 * of the next integer — so the two always agree. Longer fractions are kept. 446 */ 447 private const SQL_INTVAL_EXACT_DECIMAL_REGEX = "'^-?[0-9]{1,9}([.][0-9]{1,6})?$'"; 286 448 287 449 /** … … 307 469 */ 308 470 private const BUNDLE_ALLOWED_FIELDS = [ 309 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',471 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 310 472 'cogs_value', 311 473 'date_created', … … 325 487 private const ALLOWED_FIELDS = [ 326 488 'simple' => [ 327 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',489 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 328 490 'cogs_value', 329 491 'date_created', … … 338 500 ], 339 501 'variable' => [ 340 'name', 'sku', ' slug', 'status', 'catalog_visibility',502 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 341 503 'cogs_value', 342 504 'date_created', … … 350 512 ], 351 513 'variation' => [ 352 'sku', ' regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status',514 'sku', 'global_unique_id', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 353 515 'cogs_value', 354 516 'description', … … 361 523 ], 362 524 'grouped' => [ 363 'name', 'sku', ' slug', 'status', 'catalog_visibility',525 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 364 526 'date_created', 365 527 'categories', 'tags', 'description', 'short_description', … … 374 536 ], 375 537 'external' => [ 376 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility',538 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility', 377 539 'cogs_value', 378 540 'date_created', … … 408 570 */ 409 571 private const DEFAULT_ALLOWED_FIELDS = [ 410 'name', 'sku', ' slug', 'status', 'catalog_visibility',572 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 411 573 'date_created', 412 574 'categories', 'tags', 'description', 'short_description', … … 506 668 string $order = 'desc' 507 669 ): array { 508 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, self::QUERY_MODE_PAGE, $orderby, $order ); 509 510 $formatted = $this->format_products_page( $query_result['products'], $include_variations, $filters ); 511 512 $response = [ 513 'products' => $formatted, 514 'total' => $query_result['total'], 515 'total_pages' => $query_result['total_pages'], 516 'page' => $page, 517 'per_page' => $per_page, 518 ]; 519 520 // Only compute the "with variations" total when the client asked for 521 // variations — skipping it when include_variations=false keeps the common 522 // case free of the extra queries. Allow disabling via filter for hosts 523 // that see measurable load from the extra COUNT query. 524 if ( 525 $include_variations 526 && (bool) apply_filters( 'wcabe_g2_compute_variation_total', true, $filters ) 527 ) { 528 $variation_total = $this->get_total_with_variations( 529 (int) $query_result['total'], 530 $filters 531 ); 532 $response['total_with_variations'] = $variation_total['total']; 533 $response['total_with_variations_is_approximate'] = $variation_total['is_approximate']; 534 } 535 536 return $response; 670 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 671 self::enter_postmeta_index_scope(); 672 try { 673 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, self::QUERY_MODE_PAGE, $orderby, $order ); 674 675 $child_sort = $include_variations ? self::resolve_child_sort( $orderby, $order ) : null; 676 $formatted = $this->format_products_page( $query_result['products'], $include_variations, $filters, $child_sort ); 677 678 // The "across all pages, with variations" count is deliberately NOT part of 679 // the page response (#274): it re-runs the whole filtered query unpaginated, 680 // which roughly doubled the request under a variation-aware filter. The 681 // client fetches it separately (get_variation_count()), keyed on the filters 682 // only, so page and sort changes reuse it. 683 return [ 684 'products' => $formatted, 685 'total' => $query_result['total'], 686 'total_pages' => $query_result['total_pages'], 687 'page' => $page, 688 'per_page' => $per_page, 689 ]; 690 } finally { 691 self::leave_postmeta_index_scope(); 692 } 693 } 694 695 /** 696 * Count the variation rows that the grid would add across ALL matching pages 697 * with include_variations on: the publish/private variations of every matching 698 * variable parent. The client adds this to the page response's `total` for the 699 * toolbar's "across all pages" figure. 700 * 701 * Served by its own route rather than get_products() (#274): it re-runs the 702 * filtered query unpaginated, and that cost belongs to the one request that 703 * needs it, not to every page and sort change. 704 * 705 * `is_approximate` is true when variation-level filters are active: the raw 706 * COUNT does not replicate variation_matches_filters(), so the figure is an 707 * upper bound. 708 * 709 * @param array $filters Sanitized filters, as for get_products(). 710 * @return array{variation_count: int, is_approximate: bool}|null Null when the 711 * `wcabe_g2_compute_variation_total` filter disables the count. 712 */ 713 public function get_variation_count( array $filters ): ?array { 714 /** 715 * Whether to compute the "with variations" count for this filter set. Hosts 716 * that see measurable load from the extra query can switch it off. 717 * 718 * @param bool $compute Default true. 719 * @param array $filters Sanitized filters. 720 */ 721 if ( ! (bool) apply_filters( 'wcabe_g2_compute_variation_total', true, $filters ) ) { 722 return null; 723 } 724 725 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 726 self::enter_postmeta_index_scope(); 727 try { 728 $variable_parent_ids = $this->get_matching_variable_parent_ids( $filters ); 729 730 // No variable parent matches: nothing to over-count, so the zero is exact. 731 if ( empty( $variable_parent_ids ) ) { 732 return [ 733 'variation_count' => 0, 734 'is_approximate' => false, 735 ]; 736 } 737 738 return [ 739 'variation_count' => $this->count_renderable_variations( $variable_parent_ids ), 740 'is_approximate' => $this->has_variation_meta_filters( $filters ), 741 ]; 742 } finally { 743 self::leave_postmeta_index_scope(); 744 } 537 745 } 538 746 … … 550 758 * @param bool $include_variations Interleave variations under their parents. 551 759 * @param array $filters Sanitized filters (variation narrowing). 760 * @param array|null $child_sort resolve_child_sort() for the request's sort, 761 * resolved ONCE by the caller (the spec table 762 * is not memoised; the export calls this per 763 * chunk). Null keeps WooCommerce's child order. 552 764 * @return array[] Formatted rows. 553 765 */ 554 private function format_products_page( array $wc_products, bool $include_variations, array $filters ): array {766 private function format_products_page( array $wc_products, bool $include_variations, array $filters, ?array $child_sort = null ): array { 555 767 // Allow Pro to prime caches before per-product formatting. 556 768 $product_ids = array_map( fn( $p ) => $p->get_id(), $wc_products ); … … 637 849 } 638 850 851 // Order each parent's surviving children by the sort column's displayed 852 // value (#247) — after narrowing (fewer ids per chunk), before priming 853 // (hydration order is display order). 854 $children_map = $this->order_children_map( $children_map, $child_sort, $filters ); 855 639 856 $variation_ids_to_prime = []; 640 857 foreach ( $children_map as $children ) { … … 660 877 } 661 878 662 $formatted = $this->interleave_variations( $formatted, $filters, $children_map );879 $formatted = $this->interleave_variations( $formatted, $filters, $children_map, $child_sort ); 663 880 } 664 881 … … 672 889 * @return array|\WP_Error 673 890 */ 674 public function get_variations( int $product_id ) { 675 $product = wc_get_product( $product_id ); 676 677 if ( ! $product ) { 678 return new \WP_Error( 679 'product_not_found', 680 __( 'Product not found.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 681 [ 'status' => 404 ] 682 ); 683 } 684 685 // instanceof, not is_type('variable'): accepts third-party variable 686 // subclasses (variable-subscription, …) whose children are real 687 // variations (#137). 688 if ( ! $product instanceof \WC_Product_Variable ) { 689 return new \WP_Error( 690 'not_variable', 691 __( 'Product is not a variable product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 692 [ 'status' => 400 ] 693 ); 694 } 695 696 $children = $product->get_children(); 697 698 // Primes post objects, postmeta, and object term caches in ~3 batch 699 // queries. Uses both flags = true intentionally: WC's data store reads 700 // ALL postmeta per product (not specific keys), and Pro filter hooks 701 // (ACF, custom meta, custom taxonomies) also read from these caches. 702 // Do not "optimize" to targeted key-specific priming — it would break 703 // the broad cache coverage and cause individual queries to reappear. 704 if ( ! empty( $children ) ) { 705 _prime_post_caches( $children, true, true ); 706 707 // Prime attachment post caches for variation images so 708 // wp_get_attachment_image_url() inside format_variation() doesn't 709 // trigger per-image DB queries. 710 $this->prime_variation_image_caches( $children ); 711 } 712 713 $variations = []; 714 715 foreach ( $children as $child_id ) { 716 $variation = wc_get_product( $child_id ); 717 if ( $variation && $variation->is_type( 'variation' ) ) { 718 $variations[] = Product_Helper::format_variation( $variation ); 719 } 720 } 721 722 return $variations; 891 public function get_variations( int $product_id, string $orderby = 'id', string $order = 'desc' ) { 892 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 893 self::enter_postmeta_index_scope(); 894 try { 895 $product = wc_get_product( $product_id ); 896 897 if ( ! $product ) { 898 return new \WP_Error( 899 'product_not_found', 900 __( 'Product not found.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 901 [ 'status' => 404 ] 902 ); 903 } 904 905 // instanceof, not is_type('variable'): accepts third-party variable 906 // subclasses (variable-subscription, …) whose children are real 907 // variations (#137). 908 if ( ! $product instanceof \WC_Product_Variable ) { 909 return new \WP_Error( 910 'not_variable', 911 __( 'Product is not a variable product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 912 [ 'status' => 400 ] 913 ); 914 } 915 916 $children = $product->get_children(); 917 918 // Same child ordering as the interleaved page (#247): the expand-mode 919 // endpoint must show the variations in the grid's active sort. 920 $child_sort = self::resolve_child_sort( $orderby, $order ); 921 $children = $this->order_children_map( [ $product_id => $children ], $child_sort, [] )[ $product_id ] ?? $children; 922 923 // Primes post objects, postmeta, and object term caches in ~3 batch 924 // queries. Uses both flags = true intentionally: WC's data store reads 925 // ALL postmeta per product (not specific keys), and Pro filter hooks 926 // (ACF, custom meta, custom taxonomies) also read from these caches. 927 // Do not "optimize" to targeted key-specific priming — it would break 928 // the broad cache coverage and cause individual queries to reappear. 929 if ( ! empty( $children ) ) { 930 _prime_post_caches( $children, true, true ); 931 932 // Prime attachment post caches for variation images so 933 // wp_get_attachment_image_url() inside format_variation() doesn't 934 // trigger per-image DB queries. 935 $this->prime_variation_image_caches( $children ); 936 } 937 938 $variations = []; 939 940 foreach ( $children as $child_id ) { 941 $variation = wc_get_product( $child_id ); 942 if ( $variation && $variation->is_type( 'variation' ) ) { 943 $variations[] = Product_Helper::format_variation( $variation ); 944 } 945 } 946 947 return $this->order_formatted_children( [ $variations ], $child_sort, [] )[0]; 948 } finally { 949 self::leave_postmeta_index_scope(); 950 } 723 951 } 724 952 … … 782 1010 string $order = 'desc' 783 1011 ): int { 784 if ( $max_rows <= 0 ) { 785 return 0; 786 } 787 788 if ( null === $per_page ) { 789 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 790 } 791 792 $ids = $this->get_matching_product_ids( $filters, $include_variations, $orderby, $order, $max_rows ); 793 $count = 0; 794 795 foreach ( array_chunk( $ids, max( 1, $per_page ) ) as $chunk ) { 796 // Hydration lives INSIDE the try: it primes caches and runs third-party 797 // format filters (Pro's ACF reads, for one), so a throw there must still 798 // release what it allocated. 799 try { 800 // One batch prime per chunk, then PK reads from the cache — the same 801 // hydration as get_products_by_ids(); order is the chunk's order. 802 Product_Helper::prime_product_caches( $chunk ); 803 804 $wc_products = []; 805 foreach ( $chunk as $id ) { 806 $wc_product = wc_get_product( $id ); 807 if ( $wc_product ) { 808 $wc_products[] = $wc_product; 1012 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 1013 self::enter_postmeta_index_scope(); 1014 try { 1015 if ( $max_rows <= 0 ) { 1016 return 0; 1017 } 1018 1019 if ( null === $per_page ) { 1020 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 1021 } 1022 1023 $ids = $this->get_matching_product_ids( $filters, $include_variations, $orderby, $order, $max_rows ); 1024 $count = 0; 1025 // Once per stream, not per chunk: resolving derives the (unmemoised) spec table. 1026 $child_sort = $include_variations ? self::resolve_child_sort( $orderby, $order ) : null; 1027 1028 foreach ( array_chunk( $ids, max( 1, $per_page ) ) as $chunk ) { 1029 // Hydration lives INSIDE the try: it primes caches and runs third-party 1030 // format filters (Pro's ACF reads, for one), so a throw there must still 1031 // release what it allocated. 1032 try { 1033 // One batch prime per chunk, then PK reads from the cache — the same 1034 // hydration as get_products_by_ids(); order is the chunk's order. 1035 Product_Helper::prime_product_caches( $chunk ); 1036 1037 $wc_products = []; 1038 foreach ( $chunk as $id ) { 1039 $wc_product = wc_get_product( $id ); 1040 if ( $wc_product ) { 1041 $wc_products[] = $wc_product; 1042 } 809 1043 } 810 } 811 812 $rows = empty( $wc_products ) 813 ? [] 814 : $this->format_products_page( $wc_products, $include_variations, $filters ); 815 816 // Interleaved variations mean a chunk can overshoot the budget 817 // mid-chunk. Slicing per chunk is equivalent to the old 818 // accumulate-then-slice: slicing a concatenation at N is slicing the 819 // last contributing chunk. 820 $remaining = $max_rows - $count; 821 if ( count( $rows ) > $remaining ) { 822 $rows = array_slice( $rows, 0, $remaining ); 823 } 824 825 $count += count( $rows ); 826 827 if ( ! empty( $rows ) ) { 828 $consumer( $rows ); 829 } 830 } finally { 831 // Drop the chunk's object graph BEFORE the cache flush — flushing the 832 // runtime cache does not release references this scope still holds. 833 unset( $wc_products, $wc_product, $rows ); 834 Product_Helper::flush_runtime_cache(); 835 836 /** 837 * Fires after each export chunk has been consumed and released. 838 * 839 * Extensions (Pro) use this to drop their own request-local caches — 840 * ACF, for instance, retains every field value it reads for the whole 841 * request, which would otherwise grow with rows × fields. 842 */ 843 do_action( 'wcabe_g2_export_after_chunk' ); 844 } 845 846 if ( $count >= $max_rows ) { 847 break; 848 } 849 } 850 851 return $count; 1044 1045 $rows = empty( $wc_products ) 1046 ? [] 1047 : $this->format_products_page( $wc_products, $include_variations, $filters, $child_sort ); 1048 1049 // Interleaved variations mean a chunk can overshoot the budget 1050 // mid-chunk. Slicing per chunk is equivalent to the old 1051 // accumulate-then-slice: slicing a concatenation at N is slicing the 1052 // last contributing chunk. 1053 $remaining = $max_rows - $count; 1054 if ( count( $rows ) > $remaining ) { 1055 $rows = array_slice( $rows, 0, $remaining ); 1056 } 1057 1058 $count += count( $rows ); 1059 1060 if ( ! empty( $rows ) ) { 1061 $consumer( $rows ); 1062 } 1063 } finally { 1064 // Drop the chunk's object graph BEFORE the cache flush — flushing the 1065 // runtime cache does not release references this scope still holds. 1066 unset( $wc_products, $wc_product, $rows ); 1067 Product_Helper::flush_runtime_cache(); 1068 1069 /** 1070 * Fires after each export chunk has been consumed and released. 1071 * 1072 * Extensions (Pro) use this to drop their own request-local caches — 1073 * ACF, for instance, retains every field value it reads for the whole 1074 * request, which would otherwise grow with rows × fields. 1075 */ 1076 do_action( 'wcabe_g2_export_after_chunk' ); 1077 } 1078 1079 if ( $count >= $max_rows ) { 1080 break; 1081 } 1082 } 1083 1084 return $count; 1085 } finally { 1086 self::leave_postmeta_index_scope(); 1087 } 852 1088 } 853 1089 … … 1059 1295 // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered — 1060 1296 // including any a third-party pre_get_posts listener spins up inside ours. Unscoped, 1061 // those queries would inherit our WHERE; and because the JOIN is added only once (the 1062 // strpos guard), a nested query could even receive the wcabe_ref_lookup WHERE WITHOUT 1063 // the JOIN and hard-fail on an unknown column. Same guard shape as 1064 // Pro_Revision_Filter_Handler::filter_post_mime_where(). 1297 // those queries would inherit our JOIN and WHERE and return a wrongly filtered 1298 // result. Same guard shape as Pro_Revision_Filter_Handler::filter_post_mime_where(). 1065 1299 $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 1066 1300 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { … … 1336 1570 } 1337 1571 1338 $this->apply_field_change( $product, $field, $value, $type ); 1572 // COGS is in the allowlist and validates numerically, but the write is 1573 // a no-op while WooCommerce's Cost of Goods Sold feature is off (#153). 1574 // Skip it here with a warning so the item never reports a value as saved 1575 // that was not; the other fields in the row still persist. 1576 if ( 'cogs_value' === $field && ! Product_Helper::is_cogs_enabled() ) { 1577 $warnings[] = __( 'Cost of goods is disabled on this site, so the cost value was not saved.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ); 1578 continue; 1579 } 1580 1581 $warnings = array_merge( $warnings, $this->apply_field_change( $product, $field, $value, $type ) ); 1339 1582 } 1340 1583 … … 1358 1601 // escape batch_update() entirely and 500 the request. Same rule, and the same 1359 1602 // reasoning, as the formatting phase below. 1603 // 1604 // Warning collection (#242): extension code that persisted its own fields on 1605 // the before-seam (an action, so its return is discarded) hands back item 1606 // warnings here, keyed by $save_token. Own try/catch, separate from the 1607 // after-seam below, so a throwing warnings callback can neither fail the 1608 // committed write nor skip the after-save notification. 1609 try { 1610 $filtered = apply_filters( 'wcabe_g2_batch_item_warnings', $warnings, $product, $changes, $save_token ); 1611 $warnings = is_array( $filtered ) ? array_values( array_filter( $filtered, 'is_string' ) ) : $warnings; 1612 } catch ( \Throwable $t ) { 1613 $warnings[] = sprintf( 1614 /* translators: %s: error message from the third-party callback */ 1615 __( 'The product was saved, but another plugin errored while reporting warnings: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1616 $t->getMessage() 1617 ); 1618 } 1619 1360 1620 try { 1361 1621 do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token ); … … 1766 2026 ]; 1767 2027 } else { 2028 $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag'; 1768 2029 foreach ( $value as $term_data ) { 1769 2030 if ( ! is_array( $term_data ) || ( ! isset( $term_data['id'] ) && ! isset( $term_data['name'] ) ) ) { … … 1774 2035 ]; 1775 2036 break; 2037 } 2038 // A NEW term (id <= 0) under a parent that no longer exists (#242): 2039 // wp_insert_term() would fail with `missing_parent` at apply time and 2040 // the term would be dropped from the list while the item reports 2041 // success. Reject the item here instead. Every offending term is 2042 // reported (a preset can carry several). Read-only: validate_item() 2043 // is a dry-run seam (Operation History pre-flight). 2044 $parent_error = self::missing_parent_error( $field, $value, $term_data, $taxonomy ); 2045 if ( null !== $parent_error ) { 2046 $errors[] = $parent_error; 1776 2047 } 1777 2048 } … … 1840 2111 __( 'SKU "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1841 2112 $sanitized 2113 ), 2114 ]; 2115 } 2116 } elseif ( 'global_unique_id' === $field ) { 2117 // GTIN (#250). Both checks run here, not only in the setter, so the 2118 // failure comes back on the field (a red cell, not a '_general' toast) 2119 // and Pro's restore preflight, which dry-runs validate_item(), sees a 2120 // GTIN another product has taken since. Uniqueness is skipped for 2121 // empty() values ('' and '0'), exactly as set_global_unique_id() does. 2122 $gtin = self::normalize_gtin( $value ); 2123 if ( ( null !== $value && ! is_scalar( $value ) ) || ! self::is_valid_gtin_format( $gtin ) ) { 2124 $errors[] = [ 2125 'field' => 'global_unique_id', 2126 'value' => $value, 2127 'message' => sprintf( 2128 /* translators: %s: the submitted GTIN / UPC / EAN / ISBN value */ 2129 __( 'GTIN "%s" is not valid. Use digits and hyphens only; an X is allowed only as the last character.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2130 is_scalar( $value ) ? (string) $value : '' 2131 ), 2132 ]; 2133 } elseif ( ! empty( $gtin ) && ! wc_product_has_global_unique_id( $product->get_id(), $gtin ) ) { 2134 $errors[] = [ 2135 'field' => 'global_unique_id', 2136 'value' => $value, 2137 'message' => sprintf( 2138 /* translators: %s: the duplicate GTIN / UPC / EAN / ISBN value */ 2139 __( 'GTIN "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2140 $gtin 1842 2141 ), 1843 2142 ]; … … 1995 2294 * @param mixed $value 1996 2295 * @param string $type 2296 * @return string[] Item warnings raised while applying (the taxonomy branch, 2297 * #242, and HTML removed by the description sanitiser, #248); 2298 * merged into the batch item's `warnings` by batch_update(). 1997 2299 * @throws \InvalidArgumentException If validation fails. 1998 2300 */ 1999 private function apply_field_change( \WC_Product $product, string $field, $value, string $type ): void{2301 private function apply_field_change( \WC_Product $product, string $field, $value, string $type ): array { 2000 2302 switch ( $field ) { 2001 2303 case 'name': … … 2005 2307 case 'sku': 2006 2308 $product->set_sku( sanitize_text_field( (string) $value ) ); 2309 break; 2310 2311 case 'global_unique_id': 2312 // Format and uniqueness were checked in validate_changes(); a GTIN 2313 // taken between the two raises WC_Data_Exception into the item's 2314 // '_general' catch. 2315 $product->set_global_unique_id( self::normalize_gtin( $value ) ); 2007 2316 break; 2008 2317 … … 2074 2383 // rather than clobbering it from the in-memory cogs_value prop. 2075 2384 // No-op when the feature is off — set_cogs_value() would emit 2076 // wc_doing_it_wrong() otherwise. 2385 // wc_doing_it_wrong() otherwise. batch_update() already skips the 2386 // field with a per-item warning before reaching here (#153); this 2387 // guard stays as defence in depth for any other caller. 2077 2388 if ( Product_Helper::is_cogs_enabled() ) { 2078 2389 $sanitized = trim( (string) $value ); … … 2141 2452 case 'categories': 2142 2453 case 'tags': 2143 $this->apply_taxonomy_change( $product, $field, $value ); 2144 break; 2454 return $this->apply_taxonomy_change( $product, $field, $value ); 2145 2455 2146 2456 case 'description': 2147 $product->set_description( wp_kses_post( (string) $value ) );2148 break;2149 2150 2457 case 'short_description': 2151 $product->set_short_description( wp_kses_post( (string) $value ) ); 2458 $sanitized = self::sanitize_description_html( (string) $value ); 2459 if ( 'description' === $field ) { 2460 $product->set_description( $sanitized['value'] ); 2461 } else { 2462 $product->set_short_description( $sanitized['value'] ); 2463 } 2464 if ( [] !== $sanitized['losses'] ) { 2465 return [ 2466 sprintf( 2467 /* translators: 1: field label (Description / Short description), 2: semicolon-separated list of HTML changes */ 2468 __( '%1$s: some HTML was removed because your account is not allowed to save it — %2$s.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2469 'description' === $field 2470 ? __( 'Description', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 2471 : __( 'Short description', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2472 implode( '; ', Html_Loss_Detector::describe( $sanitized['losses'] ) ) 2473 ), 2474 ]; 2475 } 2152 2476 break; 2153 2477 … … 2262 2586 break; 2263 2587 } 2588 2589 return []; 2264 2590 } 2265 2591 … … 2363 2689 * Accepts an array of {id, name} objects. 2364 2690 * - id > 0: use existing term ID. 2365 * - id <= 0 + name: look up by name or create new term. 2691 * - id <= 0 + name: look up by name AND parent (hierarchical) or create 2692 * the term under that parent. 2366 2693 * 2367 2694 * @param \WC_Product $product 2368 2695 * @param string $field 'categories' or 'tags'. 2369 2696 * @param mixed $value Array of {id, name} objects. 2370 */ 2371 private function apply_taxonomy_change( \WC_Product $product, string $field, $value ): void { 2697 * @return string[] Item warnings: one per new term that could not be created 2698 * and one per existing term id that no longer resolves. 2699 */ 2700 private function apply_taxonomy_change( \WC_Product $product, string $field, $value ): array { 2372 2701 if ( ! is_array( $value ) ) { 2373 return ;2702 return []; 2374 2703 } 2375 2704 2376 2705 $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag'; 2377 2706 $term_ids = []; 2707 $warnings = []; 2378 2708 2379 2709 foreach ( $value as $term_data ) { … … 2384 2714 $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0; 2385 2715 $name = isset( $term_data['name'] ) ? sanitize_text_field( (string) $term_data['name'] ) : ''; 2716 $parent = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0; 2386 2717 2387 2718 if ( $term_id > 0 ) { … … 2389 2720 if ( $term && ! is_wp_error( $term ) ) { 2390 2721 $term_ids[] = $term_id; 2722 } else { 2723 // An existing term deleted between load and save: never drop it 2724 // silently while the item reports success (#242 follow-up). 2725 $warnings[] = self::term_missing_warning( $term_id, $name ); 2391 2726 } 2392 2727 } elseif ( $name ) { 2393 // Look up existing term by name .2394 $existing = get_term_by( 'name', $name, $taxonomy);2395 if ( $existing ) {2396 $term_ids[] = $existing ->term_id;2728 // Look up existing term by name AND parent. 2729 $existing_id = Term_Helper::find_existing_term( $name, $taxonomy, $parent ); 2730 if ( $existing_id > 0 ) { 2731 $term_ids[] = $existing_id; 2397 2732 } else { 2398 2733 // Create new term. 2399 2734 $insert_args = []; 2400 if ( ! empty( $term_data['parent'] )) {2401 $insert_args['parent'] = absint( $term_data['parent'] );2735 if ( $parent > 0 ) { 2736 $insert_args['parent'] = $parent; 2402 2737 } 2403 2738 $result = wp_insert_term( $name, $taxonomy, $insert_args ); 2404 2739 if ( ! is_wp_error( $result ) ) { 2405 2740 $term_ids[] = $result['term_id']; 2741 } else { 2742 // validate_changes() already rejects a missing parent; this is the 2743 // residual race (parent deleted between validate and insert, or any 2744 // other insert failure). Never drop the term silently (#242). 2745 $warnings[] = self::term_create_failed_warning( $name, $result ); 2406 2746 } 2407 2747 } … … 2414 2754 $product->set_tag_ids( $term_ids ); 2415 2755 } 2756 2757 return $warnings; 2758 } 2759 2760 /** 2761 * Whether a parent term id does NOT resolve in the given taxonomy. 2762 * 2763 * Read-only. A parent belonging to another taxonomy counts as missing 2764 * (get_term() returns a WP_Error for a taxonomy mismatch). 2765 * 2766 * @param int $parent Parent term id (> 0). 2767 * @param string $taxonomy Taxonomy the new term will be created in. 2768 */ 2769 public static function term_parent_missing( int $parent, string $taxonomy ): bool { 2770 $term = get_term( $parent, $taxonomy ); 2771 return ! $term || is_wp_error( $term ); 2772 } 2773 2774 /** 2775 * Validation error for a NEW term (id <= 0) whose parent no longer resolves, 2776 * or null when the term data does not describe that case. 2777 * 2778 * Shared with extension validators (Pro custom taxonomies) so both surfaces 2779 * decide and word the rejection identically. Read-only. 2780 * 2781 * @param string $field The changes key the error is reported under. 2782 * @param mixed $value The full field value (echoed back in the error). 2783 * @param array $term_data One {id, name, parent} entry of that value. 2784 * @param string $taxonomy Taxonomy the term would be created in. 2785 * @return array{field: string, value: mixed, message: string}|null 2786 */ 2787 public static function missing_parent_error( string $field, $value, array $term_data, string $taxonomy ): ?array { 2788 $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0; 2789 $parent = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0; 2790 if ( $term_id > 0 || $parent <= 0 || ! self::term_parent_missing( $parent, $taxonomy ) ) { 2791 return null; 2792 } 2793 2794 return [ 2795 'field' => $field, 2796 'value' => $value, 2797 'message' => sprintf( 2798 /* translators: %s: name of the new term */ 2799 __( 'Parent term no longer exists for "%s".', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2800 sanitize_text_field( (string) ( $term_data['name'] ?? '' ) ) 2801 ), 2802 ]; 2803 } 2804 2805 /** 2806 * Item warning for an existing term id that no longer resolves at save time. 2807 * 2808 * Shared with extension save handlers (Pro custom taxonomies). 2809 * 2810 * @param int $term_id The term id the client sent. 2811 * @param string $name Sanitized term name as the client knew it ('' if absent). 2812 */ 2813 public static function term_missing_warning( int $term_id, string $name ): string { 2814 return sprintf( 2815 /* translators: 1: term name, 2: term id */ 2816 __( 'Term "%1$s" (ID %2$d) no longer exists and was skipped.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2817 $name, 2818 $term_id 2819 ); 2820 } 2821 2822 /** 2823 * Item warning for a new term that wp_insert_term() refused to create. 2824 * 2825 * Shared with extension save handlers (Pro custom taxonomies) so the two 2826 * surfaces word the same failure the same way. 2827 * 2828 * @param string $name Sanitized term name. 2829 * @param \WP_Error $error The wp_insert_term() error. 2830 */ 2831 public static function term_create_failed_warning( string $name, \WP_Error $error ): string { 2832 return sprintf( 2833 /* translators: 1: term name, 2: WordPress error message */ 2834 __( 'Term "%1$s" could not be created and was skipped: %2$s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2835 $name, 2836 $error->get_error_message() 2837 ); 2416 2838 } 2417 2839 … … 2468 2890 ): array { 2469 2891 $ids_only_all_variable = ( self::QUERY_MODE_IDS_ALL_VARIABLE === $mode ); 2892 2893 // Per-invocation marker token (see PRODUCTS_QUERY_VAR): the depth this call's 2894 // query will run at. Minted before any closure below is built — they capture it 2895 // by value at construction. The depth itself is only bumped around the query. 2896 $token = 'wcabe_' . ( self::$products_query_depth + 1 ); 2897 2898 // Set by the WC-args callback and the posts_where sentinel once they see a query 2899 // carrying THIS token. Checked after the query: a lost token would otherwise make 2900 // every scoped callback bail and return the unfiltered catalogue silently. 2901 $wc_args_seen = false; 2902 $token_seen = false; 2470 2903 2471 2904 // Every non-default sort is applied by build_sort_clauses() below, which … … 2615 3048 $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value ); 2616 3049 2617 // Category filter — exact matching (include_children => false). 3050 // Category filter — exact matching (include_children => false). Only an exact 3051 // 'not_in' excludes (#147); anything else keeps the default include. 2618 3052 if ( ! empty( $filters['category'] ) ) { 2619 3053 $args['category'] = array_map( 'absint', (array) $filters['category'] ); 3054 if ( 'not_in' === ( $filters['category_operator'] ?? 'in' ) ) { 3055 $args['wcabe_category_operator'] = 'not_in'; 3056 } 2620 3057 } 2621 3058 … … 2770 3207 $has_sale_date_filter = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] ) 2771 3208 || isset( $args['wcabe_sale_end_min'] ) || isset( $args['wcabe_sale_end_max'] ); 2772 2773 // Hook for exact category matching, multi-value stock_status, price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 2774 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 2775 2776 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array { 3209 // WC's own query var, read post-hook like the wcabe_* args above. 3210 $has_stock_status_filter = ! empty( $args['stock_status'] ); 3211 3212 // Hook for exact category matching, stock_status (multi-value, or handed to the combined callback with variations), price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 3213 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || ( $include_variations && $has_stock_status_filter ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 3214 3215 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations, $token, &$wc_args_seen ): array { 3216 // This filter fires for every wc_get_products() call while registered; a nested 3217 // Woo query from a third-party listener passing its own `category` or a 3218 // multi-value `stock_status` must keep WC's semantics, not get ours. Only the 3219 // args carrying this invocation's token are rewritten (see PRODUCTS_QUERY_VAR). 3220 if ( ( $query_vars[ self::PRODUCTS_QUERY_VAR ] ?? null ) !== $token ) { 3221 return $wp_query_args; 3222 } 3223 $wc_args_seen = true; 3224 2777 3225 // Exact category matching — replace WC's default tax_query with include_children => false. 2778 3226 if ( ! empty( $query_vars['category'] ) ) { … … 2790 3238 } 2791 3239 2792 $ wp_query_args['tax_query'][]= [3240 $category_clause = [ 2793 3241 'taxonomy' => 'product_cat', 2794 3242 'field' => 'term_id', … … 2796 3244 'include_children' => false, 2797 3245 ]; 2798 } 2799 2800 // Multi-value stock_status — replace WC's single-value meta_query. 2801 if ( ! empty( $query_vars['stock_status'] ) && is_array( $query_vars['stock_status'] ) && count( $query_vars['stock_status'] ) > 1 ) { 2802 // Remove WC's default _stock_status meta_query. 3246 3247 // Exclude mode (#147): products in none of the terms, uncategorized ones 3248 // included. Parents only — variations follow their parent. NOT IN over no 3249 // real term would match the whole catalogue, so the controllers' [0] 3250 // "match nothing" marker for an unparseable value stays IN: a request whose 3251 // categories could not be read must never widen to everything. Accepted 3252 // edges: excluding only categories that have since been deleted (a stale 3253 // saved view) returns every product, which is true — nothing is in them; 3254 // in WPML's all-languages mode the other-language copies of an excluded 3255 // product are not excluded; PublishPress revisions (no product_cat terms) 3256 // pass. 3257 if ( 'not_in' === ( $query_vars['wcabe_category_operator'] ?? '' ) && array_filter( $category_ids ) ) { 3258 $category_clause['operator'] = 'NOT IN'; 3259 } 3260 3261 $wp_query_args['tax_query'][] = $category_clause; 3262 } 3263 3264 // Remove WC's default _stock_status meta_query (the parent's own row only). 3265 $strip_stock_status_clause = static function ( array $wp_query_args ): array { 2803 3266 if ( ! empty( $wp_query_args['meta_query'] ) ) { 2804 3267 $wp_query_args['meta_query'] = array_filter( … … 2810 3273 $wp_query_args['meta_query'] = array_values( $wp_query_args['meta_query'] ); 2811 3274 } 3275 return $wp_query_args; 3276 }; 3277 3278 if ( $include_variations && ! empty( $query_vars['stock_status'] ) ) { 3279 // With variations the combined posts_where callback below owns stock 3280 // status — `own OR a renderable child reports it` (#273) — so WC's 3281 // own-row clause must not AND in on top and veto the child branch. 3282 $wp_query_args = $strip_stock_status_clause( $wp_query_args ); 3283 } elseif ( ! empty( $query_vars['stock_status'] ) && is_array( $query_vars['stock_status'] ) && count( $query_vars['stock_status'] ) > 1 ) { 3284 // Multi-value stock_status — replace WC's single-value meta_query. 3285 $wp_query_args = $strip_stock_status_clause( $wp_query_args ); 2812 3286 2813 3287 $wp_query_args['meta_query'][] = [ … … 2986 3460 // Stock quantity range filtering via _stock meta. 2987 3461 // When include_variations is false, standard meta_query applies. 2988 // When include_variations is true, we skip the meta_query here and use a 2989 // posts_where hook instead, because variable parents often have _stock = NULL 2990 // in the DB (stock tracked per-variation) and WP meta_query can't match NULL 2991 // meta_value. The posts_where hook adds an OR for NULL values so variable 2992 // parents survive the main query; interleave_variations() filters their 2993 // variations later. 3462 // When include_variations is true it is handled by the combined posts_where 3463 // hook below instead, so a variable parent can surface through a child 3464 // variation whose (inherited) quantity matches, while a product whose own 3465 // _stock is NULL — WC's stored value for "does not manage stock" — never 3466 // matches on its own row (#259). 2994 3467 $has_stock_min = isset( $query_vars['wcabe_stock_qty_min'] ); 2995 3468 $has_stock_max = isset( $query_vars['wcabe_stock_qty_max'] ); … … 3109 3582 // variation with a matching _variation_description so the parent survives 3110 3583 // the main query and interleave_variations() can load its children. 3584 // Only publish/private variations count, mirroring the render path 3585 // (WC_Product_Variable::get_children()) and the with-variations count, so a 3586 // draft/pending/trashed child cannot surface a parent that renders empty. 3111 3587 $description_where_callback = null; 3112 3588 if ( $search_description_term ) { … … 3119 3595 // conversion must not surface its now-simple parent. 3120 3596 $is_variable = $this->sql_is_variable_product(); 3121 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()); the search term is passed through prepare() via %s.3597 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()) and sql_postmeta_by_post_id() returns this class's own postmeta table reference (plus the post_id index hint only where that key exists); the search term is passed through prepare() via %s. 3122 3598 $where .= $wpdb->prepare( 3123 3599 " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 3124 3600 . " SELECT p.post_parent FROM {$wpdb->posts} p" 3125 . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"3601 . ' INNER JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . ' ON p.ID = pm.post_id' 3126 3602 . " WHERE p.post_type = 'product_variation'" 3603 . " AND p.post_status IN ('publish','private')" 3127 3604 . " AND pm.meta_key = '_variation_description'" 3128 3605 . " AND pm.meta_value LIKE %s" … … 3131 3608 $desc_like 3132 3609 ); 3133 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 3610 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared 3134 3611 } else { 3135 3612 $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like ); … … 3195 3672 } 3196 3673 3197 // Stock qty filtering via posts_where when include_variations is true.3198 // WP meta_query can't match NULL meta_value, so we use raw SQL that allows3199 // products with NULL _stock through (variable parents track stock per-variation).3200 // interleave_variations() handles filtering variations; variation_matches_filters()3201 // handles filtering simple/non-variable products post-query if needed.3202 $stock_qty_join_callback = null;3203 $stock_qty_where_callback = null;3204 3205 if ( $include_variations && $has_stock_qty_filter ) {3206 global $wpdb;3207 3208 $stock_qty_min = isset( $filters['stock_qty_min'] ) && '' !== $filters['stock_qty_min']3209 ? (float) $filters['stock_qty_min'] : null;3210 $stock_qty_max = isset( $filters['stock_qty_max'] ) && '' !== $filters['stock_qty_max']3211 ? (float) $filters['stock_qty_max'] : null;3212 3213 $stock_qty_join_callback = function ( string $join ) use ( $wpdb ): string {3214 if ( strpos( $join, 'wcabe_stock_meta' ) === false ) {3215 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_stock_meta ON ( {$wpdb->posts}.ID = wcabe_stock_meta.post_id AND wcabe_stock_meta.meta_key = '_stock' ) ";3216 }3217 return $join;3218 };3219 3220 $stock_qty_where_callback = function ( string $where ) use ( $wpdb, $stock_qty_min, $stock_qty_max ): string {3221 if ( null !== $stock_qty_min && null !== $stock_qty_max ) {3222 $where .= $wpdb->prepare(3223 " AND ( ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) BETWEEN %f AND %f ) OR wcabe_stock_meta.meta_value IS NULL ) ",3224 $stock_qty_min,3225 $stock_qty_max3226 );3227 } elseif ( null !== $stock_qty_min ) {3228 $where .= $wpdb->prepare(3229 " AND ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) >= %f OR wcabe_stock_meta.meta_value IS NULL ) ",3230 $stock_qty_min3231 );3232 } else {3233 $where .= $wpdb->prepare(3234 " AND ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) <= %f OR wcabe_stock_meta.meta_value IS NULL ) ",3235 $stock_qty_max3236 );3237 }3238 return $where;3239 };3240 }3241 3242 3674 // Product URL filtering (external/affiliate link, _product_url meta). 3243 // Raw SQL rather than meta_query for the same reason as stock qty above:3675 // Raw SQL rather than meta_query because 3244 3676 // WP's meta_query cannot express "meta row missing", which the is_empty 3245 3677 // operator needs. Every column is alias-qualified — an unqualified 3246 3678 // post_id/meta_key/meta_value becomes ambiguous the moment another meta 3247 3679 // join (e.g. stock qty) is active in the same query. 3248 $product_url_join_callback = null; 3249 $product_url_where_callback = null; 3250 $product_url_marker_callback = null; 3680 $product_url_join_callback = null; 3681 $product_url_where_callback = null; 3251 3682 3252 3683 if ( $has_product_url_filter ) { 3253 3684 global $wpdb; 3254 3685 3255 // posts_join / posts_where fire on EVERY WP_Query for as long as they are 3256 // registered — including any a third-party pre_get_posts listener spins up 3257 // inside ours. Unscoped, those queries would inherit our WHERE; and because 3258 // the JOIN is added only once (the strpos guard), a nested query could even 3259 // receive the WHERE WITHOUT the JOIN and hard-fail on an unknown column. 3260 // So both callbacks below bail unless the query carries our private marker. 3261 // The marker filter itself also fires for every wc_get_products() call while 3262 // registered, so it only stamps queries whose ORIGINATING query vars carry 3263 // our sentinel (set on $args below; WC_Object_Query keeps unknown args via 3264 // wp_parse_args and hands them to this filter as its second parameter) — 3265 // a nested Woo product query never gets stamped. Same guard shape as the 3266 // SORT_QUERY_VAR marker. 3267 $args[ self::PRODUCT_URL_QUERY_VAR ] = true; 3268 3269 $product_url_marker_callback = static function ( array $wp_query_args, array $query_vars ): array { 3270 if ( ! empty( $query_vars[ self::PRODUCT_URL_QUERY_VAR ] ) ) { 3271 $wp_query_args[ self::PRODUCT_URL_QUERY_VAR ] = true; 3272 } 3273 return $wp_query_args; 3274 }; 3275 3276 $product_url_join_callback = function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 3277 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 3278 return $join; 3279 } 3686 $product_url_join_callback = function ( string $join ) use ( $wpdb ): string { 3280 3687 if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) { 3281 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_metaON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) ";3688 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_product_url_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) "; 3282 3689 } 3283 3690 return $join; … … 3286 3693 $is_external_sql = $this->sql_is_external_product(); 3287 3694 3288 $product_url_where_callback = function ( string $where, \WP_Query $query ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 3289 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 3290 return $where; 3291 } 3695 $product_url_where_callback = function ( string $where ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 3292 3696 3293 3697 $col = 'wcabe_product_url_meta.meta_value'; … … 3350 3754 3351 3755 // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date, 3352 // manage stock, backorders ──3756 // manage stock, backorders, stock quantity, stock status ── 3353 3757 // 3354 // ONE callback pair covers all sixdeliberately. Each of them can surface a3758 // ONE callback pair covers all eight deliberately. Each of them can surface a 3355 3759 // variable parent either on its OWN value or through a child variation, and 3356 3760 // emitting them as separate `( own_i OR child_i )` clauses would let *different* … … 3370 3774 // single-filter behaviour is unchanged. 3371 3775 // 3372 // SCOPE: these six filters only. Price, stock status, stock quantity, SKU 3373 // search and description search surface parents through other mechanisms 3374 // (meta_query, the OR-NULL stock posts_where above, IN subqueries below) and 3375 // are NOT folded in — a filter from this group combined with one of those can 3376 // still produce an empty parent row. Known and accepted; folding the rest in 3377 // is tracked separately. 3776 // SCOPE: these eight filters only. Price, SKU search and description search 3777 // surface parents through other mechanisms (meta_query, IN subqueries below) 3778 // and are NOT folded in — a filter from this group combined with one of those 3779 // can still produce an empty parent row. Known and accepted. 3780 // Stock quantity joined the group in #259: its old standalone callback let 3781 // every product with a NULL own _stock through (WC stores NULL for "does not 3782 // manage stock"), so non-variable products matched any range and non-managing 3783 // variable parents surfaced with no matching child. 3784 // Stock status joined in #273: on WC's own meta_query a variable parent could 3785 // match only on its own _stock_status, which WC syncs from the children 3786 // (instock while ANY child is), so "out of stock" never reached the 3787 // out-of-stock variations of a family that still had one in stock. 3378 3788 // 3379 3789 // The child predicates themselves live in build_variation_child_predicates() 3380 3790 // ('surface' mode here), shared with the candidate query that pre-narrows 3381 // which children get hydrated (narrow_children_by_sql(), #218). That query 3382 // additionally narrows by stock status and stock quantity — as a pre-filter 3383 // only; it never changes which parents this callback surfaces. 3791 // which children get hydrated (narrow_children_by_sql(), #218). 3384 3792 // 3385 3793 // The child status list matches WC get_children() (publish/private only) so a … … 3389 3797 // its now-simple parent (interleaving only narrows children of variable 3390 3798 // parents, so such a false positive is uncorrectable downstream). COGS, sale 3391 // dates, manage stock and backorders participate only when include_variations3392 // is on; with it off they stay on their meta_query paths and never reach this3393 // callback, so with variations off only menu order and image date are emitted3394 // here.3799 // dates, manage stock, backorders, stock quantity and stock status participate 3800 // only when include_variations is on; with it off they stay on their 3801 // meta_query paths and never reach this callback, so with variations off only 3802 // menu order and image date are emitted here. 3395 3803 // 3396 3804 // Manage stock / backorders child predicates use the value WooCommerce REPORTS … … 3410 3818 // The OWN predicates never coalesce: a product with no row must behave the same 3411 3819 // as on the variation-off meta_query path, which requires a real row (#207). 3820 // The stock quantity own predicate is that meta_query's shape (a 3821 // DECIMAL(10,2) cast of the own _stock, NULL failing), so non-variable rows are 3822 // identical with the toggle on or off (for bounds of up to six decimals, the 3823 // %f rendering the other own predicates share); its child predicate inherits like 3824 // backorders and is never tighter than PHP (see the builder). 3825 // The stock status own predicate is WC's meta_query shape too (any own 3826 // _stock_status row in the list, collation-bound, a missing row failing), so 3827 // non-variable rows are again identical with the toggle on or off. It is an 3828 // EXISTS, not a joined alias: the meta_query it replaces brought WP_Query's 3829 // GROUP BY with it, and a plain join would duplicate a product that carries two 3830 // _stock_status rows. Its child predicate is byte-exact (see the builder). 3412 3831 $combined_join_callback = null; 3413 3832 $combined_where_callback = null; … … 3435 3854 $use_ms = $include_variations && $has_manage_stock_filter; 3436 3855 $use_bk = $include_variations && $has_backorders_filter; 3856 $use_qty = $include_variations && $has_stock_qty_filter; 3857 $use_ss = $include_variations && $has_stock_status_filter; 3437 3858 3438 3859 $manage_stock_values = $use_ms ? array_values( array_map( 'strval', (array) $args['wcabe_manage_stock'] ) ) : []; 3439 3860 $backorders_values = $use_bk ? array_values( array_map( 'strval', (array) $args['wcabe_backorders'] ) ) : []; 3440 3441 if ( $use_cogs || $use_sale || $use_menu || $use_image || $use_ms || $use_bk ) { 3861 // (array): WC's query var also takes a single string, and a hook may set one. 3862 $stock_status_values = $use_ss ? array_values( array_map( 'strval', (array) $args['stock_status'] ) ) : []; 3863 3864 // Keyed on $args like the value lists above (see the surface spec below). 3865 $stock_qty_min = $use_qty && isset( $args['wcabe_stock_qty_min'] ) ? (float) $args['wcabe_stock_qty_min'] : null; 3866 $stock_qty_max = $use_qty && isset( $args['wcabe_stock_qty_max'] ) ? (float) $args['wcabe_stock_qty_max'] : null; 3867 3868 if ( $use_cogs || $use_sale || $use_menu || $use_image || $use_ms || $use_bk || $use_qty || $use_ss ) { 3442 3869 global $wpdb; 3443 3870 … … 3467 3894 ); 3468 3895 3469 // Only COGS, sale dates, image date, manage stock and backorders need3470 // parent-side joins; menu_order reads a native wp_posts column.3471 if ( $use_cogs || $use_sale || $use_image || $use_ms || $use_bk ) {3472 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $use_ms, $use_bk, $ sale_predicates ): string {3896 // Only COGS, sale dates, image date, manage stock, backorders and stock 3897 // quantity need parent-side joins; menu_order reads a native wp_posts column. 3898 if ( $use_cogs || $use_sale || $use_image || $use_ms || $use_bk || $use_qty ) { 3899 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $use_ms, $use_bk, $use_qty, $sale_predicates ): string { 3473 3900 if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) { 3474 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_metaON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) ";3901 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_cogs_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) "; 3475 3902 } 3476 3903 3477 // The parent's manage flag is needed for BOTH filters: backorders3478 // inherit from the parent only while the parent manages stock.3479 if ( ( $use_ms || $use_bk ) && strpos( $join, 'wcabe_ms_meta' ) === false ) {3480 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_ms_meta ON ( {$wpdb->posts}.ID = wcabe_ms_meta.post_id AND wcabe_ms_meta.meta_key = '_manage_stock' ) ";3904 // Own _stock for the own predicate, and the parent's _stock that a 3905 // non-managing child inherits in the child predicate. 3906 if ( $use_qty && strpos( $join, 'wcabe_stock_meta' ) === false ) { 3907 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_stock_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_stock_meta.post_id AND wcabe_stock_meta.meta_key = '_stock' ) "; 3481 3908 } 3482 3909 3910 // The parent's manage flag is needed for all three stock filters: 3911 // backorders and stock quantity inherit from the parent only while 3912 // the parent manages stock. 3913 if ( ( $use_ms || $use_bk || $use_qty ) && strpos( $join, 'wcabe_ms_meta' ) === false ) { 3914 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_ms_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_ms_meta.post_id AND wcabe_ms_meta.meta_key = '_manage_stock' ) "; 3915 } 3916 3483 3917 if ( $use_bk && strpos( $join, 'wcabe_bk_meta' ) === false ) { 3484 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_bk_metaON ( {$wpdb->posts}.ID = wcabe_bk_meta.post_id AND wcabe_bk_meta.meta_key = '_backorders' ) ";3918 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_bk_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_bk_meta.post_id AND wcabe_bk_meta.meta_key = '_backorders' ) "; 3485 3919 } 3486 3920 3487 3921 foreach ( $sale_predicates as $range ) { 3488 3922 if ( strpos( $join, $range['parent_alias'] ) === false ) { 3489 $join .= " LEFT JOIN {$wpdb->postmeta} AS {$range['parent_alias']}ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) ";3923 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( $range['parent_alias'] ) . " ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) "; 3490 3924 } 3491 3925 } … … 3499 3933 // so a _thumbnail_id pointing at a non-attachment is excluded here 3500 3934 // and renders blank there. 3501 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_metaON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) ";3935 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_img_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) "; 3502 3936 $join .= " LEFT JOIN {$wpdb->posts} AS wcabe_img ON ( wcabe_img.ID = CAST( wcabe_img_meta.meta_value AS UNSIGNED ) AND wcabe_img.post_type = 'attachment' ) "; 3503 3937 } … … 3512 3946 // spec keyed on $args, not $filters — a wcabe_g2_get_products_args hook 3513 3947 // may legitimately change which PARENTS surface (#218 keeps that intact). 3514 // stock_status / stock_qty are deliberately absent: they surface parents3515 // through their own mechanisms (see the SCOPE note) and only the3516 // candidate query (narrow_children_by_sql()) narrows children by them.3517 3948 // The builder reads every axis through `! empty( $spec[...] )`, so an 3518 3949 // absent key and a null one are the same thing. 3950 // The stock-status matches (#276), read once from the _stock_status rows 3951 // instead of one probe per product and per variation: the products whose 3952 // own row matches, and the parents of the variations that match. Null: 3953 // no pre-read, both branches keep their EXISTS. 3954 $ss_ids = $use_ss ? $this->stock_status_surface_ids( $stock_status_values, $filters ) : null; 3955 3519 3956 $surface_spec = [ 3520 3957 'cogs' => $use_cogs ? [ 'min' => $cogs_min, 'max' => $cogs_max ] : null, … … 3524 3961 'manage_stock' => $use_ms ? $manage_stock_values : null, 3525 3962 'backorders' => $use_bk ? $backorders_values : null, 3963 'stock_qty' => $use_qty ? [ 'min' => $stock_qty_min, 'max' => $stock_qty_max ] : null, 3964 'stock_status' => $use_ss ? $stock_status_values : null, 3526 3965 // The EXISTS is correlated to the outer parent row, whose 3527 // _manage_stock / _backorders rows the join callback above already 3528 // attaches (wcabe_ms_meta whenever $use_ms || $use_bk, wcabe_bk_meta 3529 // whenever $use_bk — exactly when the builder needs each). Reuse them 3530 // instead of re-joining the same rows once per child row scanned. 3531 'parent_ms_col' => 'wcabe_ms_meta.meta_value', 3532 'parent_bk_col' => 'wcabe_bk_meta.meta_value', 3966 // _manage_stock / _backorders / _stock rows the join callback above 3967 // already attaches (wcabe_ms_meta whenever $use_ms || $use_bk || 3968 // $use_qty, wcabe_bk_meta whenever $use_bk, wcabe_stock_meta whenever 3969 // $use_qty — exactly when the builder needs each). Reuse them instead 3970 // of re-joining the same rows once per child row scanned. 3971 'parent_ms_col' => 'wcabe_ms_meta.meta_value', 3972 'parent_bk_col' => 'wcabe_bk_meta.meta_value', 3973 'parent_stock_col' => 'wcabe_stock_meta.meta_value', 3533 3974 ]; 3534 3975 … … 3542 3983 $use_ms, 3543 3984 $use_bk, 3985 $use_qty, 3986 $use_ss, 3544 3987 $sale_predicates, 3545 3988 $cogs_min, … … 3551 3994 $manage_stock_values, 3552 3995 $backorders_values, 3996 $stock_qty_min, 3997 $stock_qty_max, 3998 $stock_status_values, 3999 $ss_ids, 3553 4000 $surface_spec 3554 4001 ): string { … … 3595 4042 } 3596 4043 4044 if ( $use_qty ) { 4045 // The variations-off meta_query's shape: a NULL or missing own 4046 // _stock fails, so a product that does not manage stock never 4047 // matches on its own row (#259). 4048 $own_parts[] = self::sql_bound( $wpdb, 'CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) )', $stock_qty_min, $stock_qty_max, '%f' ); 4049 } 4050 4051 if ( $use_ss ) { 4052 // WC's meta_query, which the filter callback stripped (see the 4053 // header note): any own _stock_status row in the list. With the 4054 // #276 pre-read, the ids that have such a row — the same test. 4055 $own_parts[] = null !== $ss_ids 4056 ? self::sql_id_in( "{$wpdb->posts}.ID", $ss_ids['own'] ) 4057 : 'EXISTS ( SELECT 1 FROM ' . self::sql_postmeta_by_post_id( 'wcabe_ss_meta' ) 4058 . " WHERE wcabe_ss_meta.post_id = {$wpdb->posts}.ID AND wcabe_ss_meta.meta_key = '_stock_status' AND " 4059 . self::sql_in( $wpdb, 'wcabe_ss_meta.meta_value', $stock_status_values ) . ' )'; 4060 } 4061 3597 4062 $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )'; 3598 4063 … … 3601 4066 $fragment = $this->build_variation_child_predicates( $surface_spec, 'surface' ); 3602 4067 if ( ! empty( $fragment['parts'] ) ) { 3603 $child = "( {$is_variable} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$fragment['joins']}" 4068 // #276: only the parents of a variation with a selected status 4069 // reach the EXISTS, which stays the gate for every axis. 4070 $prefilter = self::sql_id_restriction( "{$wpdb->posts}.ID", null !== $ss_ids ? $ss_ids['parents'] : null ); 4071 $child = "( {$is_variable}{$prefilter} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$fragment['joins']}" 3604 4072 . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'" 3605 4073 . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $fragment['parts'] ) . ' ) )'; … … 3626 4094 // (order_by_price_{asc,desc}_post_clauses in includes/class-wc-query.php). 3627 4095 $sort_clauses_callback = null; 3628 $sort_marker_callback = null;3629 4096 3630 4097 $sort_spec = ( 'id' !== $orderby && ! $ids_only_all_variable ) … … 3633 4100 3634 4101 if ( null !== $sort_spec ) { 3635 // posts_clauses fires on EVERY WP_Query for as long as it is registered —3636 // including any a third-party pre_get_posts listener spins up inside ours —3637 // and this callback REPLACES the ORDER BY wholesale, so unscoped it would3638 // silently reorder (and join a foreign table into) a nested query. Both3639 // callbacks below bail unless the query carries our private marker. The3640 // marker filter itself also fires for every wc_get_products() call while3641 // registered, so it only stamps queries whose ORIGINATING query vars carry3642 // our sentinel (set on $args here; WC_Object_Query keeps unknown args via3643 // wp_parse_args and hands them to this filter as its second parameter) —3644 // a nested Woo product query never gets stamped. Same guard shape as the3645 // PRODUCT_URL_QUERY_VAR marker (REF_SEARCH_QUERY_VAR is safe differently:3646 // search_products() stamps its own WP_Query args directly, no filter).3647 $args[ self::SORT_QUERY_VAR ] = true;3648 3649 $sort_marker_callback = static function ( array $wp_query_args, array $query_vars ): array {3650 if ( ! empty( $query_vars[ self::SORT_QUERY_VAR ] ) ) {3651 $wp_query_args[ self::SORT_QUERY_VAR ] = true;3652 }3653 return $wp_query_args;3654 };3655 3656 4102 $sort_clauses_callback = self::build_sort_clauses( $sort_spec, 'ASC' === $wc_order ); 4103 } 4104 4105 // posts_join / posts_where / posts_clauses fire on EVERY WP_Query for as long 4106 // as they are registered — including any a third-party pre_get_posts listener 4107 // (multilingual, membership, …) spins up inside ours. Unscoped, those queries 4108 // would inherit our JOINs and WHERE and return a wrongly filtered result — and 4109 // the sort callback REPLACES the ORDER BY wholesale, so it would silently 4110 // reorder them too. Every callback registered below therefore bails unless the 4111 // query carries this invocation's token under PRODUCTS_QUERY_VAR. 4112 // 4113 // How the token reaches WP_Query: it is set on the wc_get_products() args, and 4114 // WC's data store passes every unmapped arg straight through to the WP_Query 4115 // args (WC_Data_Store_WP::get_wp_query_args(), verified on WC 11.1) — that 4116 // passthrough is the load-bearing step, so this `$args` stamp must stay. The 4117 // marker filter below is defence in depth only: it re-stamps the token from the 4118 // ORIGINATING query vars in case a WC version or a lower-priority listener 4119 // strips unknown keys. A nested wc_get_products() started by a third-party 4120 // listener is not stamped because its own args do not carry the key (unless 4121 // the listener clones the originating args wholesale — accepted, see 4122 // PRODUCTS_QUERY_VAR), and a recursive service call mints its own token. (REF_SEARCH_QUERY_VAR is safe 4123 // differently: search_products() stamps its own WP_Query args directly.) 4124 // 4125 // The one thing the guards cannot see is the token NOT arriving at all (a 4126 // listener AFTER the re-stamp that rebuilds the args from a whitelist, or a 4127 // pre_get_posts listener that reassigns query_vars): every callback would bail 4128 // and the grid would show the whole catalogue with its filter chips still on. 4129 // So the sentinel below records whether any WP_Query carried this token, and 4130 // the check after the query turns a lost token into an exception rather than 4131 // a silently unfiltered result set. 4132 $args[ self::PRODUCTS_QUERY_VAR ] = $token; 4133 4134 $products_marker_callback = static function ( array $wp_query_args, array $query_vars ): array { 4135 if ( ! empty( $query_vars[ self::PRODUCTS_QUERY_VAR ] ) ) { 4136 $wp_query_args[ self::PRODUCTS_QUERY_VAR ] = $query_vars[ self::PRODUCTS_QUERY_VAR ]; 4137 } 4138 return $wp_query_args; 4139 }; 4140 4141 $token_sentinel = static function ( string $where, \WP_Query $query ) use ( $token, &$token_seen ): string { 4142 if ( $query->get( self::PRODUCTS_QUERY_VAR ) === $token ) { 4143 $token_seen = true; 4144 } 4145 return $where; 4146 }; 4147 4148 // Every posts_* callback this invocation registers, in registration order, 4149 // wrapped by scoped_to() so it fires for this invocation's query only. The 4150 // clause order is preserved from before the wrapping: joins in the order the 4151 // WHEREs reference their aliases, WHEREs as AND-appended predicates. 4152 $scoped_callbacks = []; 4153 foreach ( [ 4154 [ 'posts_join', $product_url_join_callback ], 4155 [ 'posts_where', $product_url_where_callback ], 4156 [ 'posts_join', $combined_join_callback ], 4157 [ 'posts_where', $combined_where_callback ], 4158 [ 'posts_join', $search_join_callback ], 4159 [ 'posts_where', $search_where_callback ], 4160 [ 'posts_where', $description_where_callback ], 4161 [ 'posts_where', $short_desc_where_callback ], 4162 [ 'posts_where', $id_where_callback ], 4163 [ 'posts_clauses', $sort_clauses_callback ], 4164 ] as [ $hook, $callback ] ) { 4165 if ( $callback ) { 4166 $scoped_callbacks[] = [ $hook, self::scoped_to( $token, $callback ) ]; 4167 } 4168 } 4169 $has_scoped_callbacks = ! empty( $scoped_callbacks ); 4170 4171 // Marker first — it must be on the query args before WP_Query is built. 4172 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $products_marker_callback, 10, 2 ); 4173 4174 if ( $has_scoped_callbacks ) { 4175 add_filter( 'posts_where', $token_sentinel, 10, 2 ); 3657 4176 } 3658 4177 … … 3666 4185 } 3667 4186 3668 if ( $stock_qty_join_callback ) { 3669 add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 ); 3670 add_filter( 'posts_where', $stock_qty_where_callback, 10, 1 ); 3671 } 3672 3673 if ( $product_url_join_callback ) { 3674 // Marker first — it must be on the query args before WP_Query is built. 3675 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10, 2 ); 3676 add_filter( 'posts_join', $product_url_join_callback, 10, 2 ); 3677 add_filter( 'posts_where', $product_url_where_callback, 10, 2 ); 3678 } 3679 3680 if ( $combined_join_callback ) { 3681 add_filter( 'posts_join', $combined_join_callback, 10, 1 ); 3682 } 3683 if ( $combined_where_callback ) { 3684 add_filter( 'posts_where', $combined_where_callback, 10, 1 ); 3685 } 3686 3687 if ( $search_join_callback ) { 3688 add_filter( 'posts_join', $search_join_callback, 10, 1 ); 3689 } 3690 if ( $search_where_callback ) { 3691 add_filter( 'posts_where', $search_where_callback, 10, 1 ); 3692 } 3693 if ( $description_where_callback ) { 3694 add_filter( 'posts_where', $description_where_callback, 10, 1 ); 3695 } 3696 if ( $short_desc_where_callback ) { 3697 add_filter( 'posts_where', $short_desc_where_callback, 10, 1 ); 3698 } 3699 3700 if ( $id_where_callback ) { 3701 add_filter( 'posts_where', $id_where_callback, 10, 1 ); 3702 } 3703 3704 if ( $sort_clauses_callback ) { 3705 // Marker first — it must be on the query args before WP_Query is built. 3706 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $sort_marker_callback, 10, 2 ); 3707 add_filter( 'posts_clauses', $sort_clauses_callback, 10, 2 ); 4187 foreach ( $scoped_callbacks as [ $hook, $callback ] ) { 4188 add_filter( $hook, $callback, 10, 2 ); 3708 4189 } 3709 4190 3710 4191 try { 4192 ++self::$products_query_depth; 3711 4193 $results = wc_get_products( $args ); 3712 4194 } finally { 4195 --self::$products_query_depth; 4196 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $products_marker_callback, 10 ); 4197 if ( $has_scoped_callbacks ) { 4198 remove_filter( 'posts_where', $token_sentinel, 10 ); 4199 } 3713 4200 if ( $needs_custom_query ) { 3714 4201 remove_filter( … … 3718 4205 ); 3719 4206 } 3720 if ( $stock_qty_join_callback ) { 3721 remove_filter( 'posts_join', $stock_qty_join_callback, 10 ); 3722 remove_filter( 'posts_where', $stock_qty_where_callback, 10 ); 3723 } 3724 if ( $product_url_join_callback ) { 3725 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10 ); 3726 remove_filter( 'posts_join', $product_url_join_callback, 10 ); 3727 remove_filter( 'posts_where', $product_url_where_callback, 10 ); 3728 } 3729 if ( $combined_join_callback ) { 3730 remove_filter( 'posts_join', $combined_join_callback, 10 ); 3731 } 3732 if ( $combined_where_callback ) { 3733 remove_filter( 'posts_where', $combined_where_callback, 10 ); 3734 } 3735 if ( $search_join_callback ) { 3736 remove_filter( 'posts_join', $search_join_callback, 10 ); 3737 } 3738 if ( $search_where_callback ) { 3739 remove_filter( 'posts_where', $search_where_callback, 10 ); 3740 } 3741 if ( $description_where_callback ) { 3742 remove_filter( 'posts_where', $description_where_callback, 10 ); 3743 } 3744 if ( $short_desc_where_callback ) { 3745 remove_filter( 'posts_where', $short_desc_where_callback, 10 ); 3746 } 3747 if ( $id_where_callback ) { 3748 remove_filter( 'posts_where', $id_where_callback, 10 ); 3749 } 3750 if ( $sort_clauses_callback ) { 3751 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $sort_marker_callback, 10 ); 3752 remove_filter( 'posts_clauses', $sort_clauses_callback, 10 ); 3753 } 4207 foreach ( $scoped_callbacks as [ $hook, $callback ] ) { 4208 remove_filter( $hook, $callback, 10 ); 4209 } 4210 } 4211 4212 if ( ( $has_scoped_callbacks && ! $token_seen ) || ( $needs_custom_query && ! $wc_args_seen ) ) { 4213 // See the sentinel comment above. Loud beats a silently unfiltered catalogue: 4214 // the same result feeds "select all matching" and every bulk operation. 4215 throw new \RuntimeException( 4216 'WCABE: the product query marker (' . esc_html( self::PRODUCTS_QUERY_VAR ) . ') did not reach the query — ' 4217 . 'a plugin rebuilt the WooCommerce product query args after ours were applied, so the active ' 4218 . 'filters and sort could not be enforced. Refusing to return an unfiltered result set.' 4219 ); 3754 4220 } 3755 4221 … … 3924 4390 3925 4391 $child_join = $negated 3926 ? "LEFT JOIN {$wpdb->postmeta} pmON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )"3927 : "INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id";4392 ? 'LEFT JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . " ON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )" 4393 : 'INNER JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . ' ON p.ID = pm.post_id'; 3928 4394 $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'"; 3929 4395 … … 4063 4529 4064 4530 /** 4531 * Result of postmeta_post_id_index_hint()'s key detection for the running 4532 * service operation, keyed by postmeta table name (switch_to_blog() changes 4533 * it): true = the `post_id` key exists and a hint may name it, false = it does 4534 * not, it is INVISIBLE / IGNORED, or detection failed. Emptied when the 4535 * outermost operation ends — see enter_postmeta_index_scope(). 4536 * 4537 * @var array<string, bool> 4538 */ 4539 private static array $postmeta_post_id_index = []; 4540 4541 /** 4542 * Nesting depth of running service operations (get_products(), 4543 * get_variations(), stream_products()) — the only lifetime the detection 4544 * result is kept for. 4545 */ 4546 private static int $postmeta_index_scope_depth = 0; 4547 4548 /** 4549 * Open a detection scope for one service operation. The detection result is 4550 * kept only while the outermost scope is open: a static would otherwise 4551 * live as long as the PHP process, and a long-lived one (a WP-CLI loop, an 4552 * Action Scheduler batch, a worker-mode server) would keep pinning a key an 4553 * index plugin or a DBA dropped in the meantime — a hard SQL error, and on the 4554 * main products query an empty grid. Every enter must be paired with 4555 * leave_postmeta_index_scope() in a `finally`. 4556 */ 4557 private static function enter_postmeta_index_scope(): void { 4558 if ( 0 === self::$postmeta_index_scope_depth ) { 4559 self::$postmeta_post_id_index = []; 4560 } 4561 ++self::$postmeta_index_scope_depth; 4562 } 4563 4564 private static function leave_postmeta_index_scope(): void { 4565 self::$postmeta_index_scope_depth = max( 0, self::$postmeta_index_scope_depth - 1 ); 4566 if ( 0 === self::$postmeta_index_scope_depth ) { 4567 self::$postmeta_post_id_index = []; 4568 } 4569 } 4570 4571 /** 4572 * ' FORCE INDEX (post_id)' for a postmeta read that must stay on WP core's 4573 * `post_id` key, or '' when that key is not proven usable in a hint. 4574 * 4575 * Why pin: while InnoDB statistics are stale (right after a bulk import) MySQL 4576 * can move a post_id-keyed postmeta read onto the meta_key index. Measured at 4577 * 20k products / 720k postmeta rows: the regular_price "has a price" probe 4578 * went from ~230ms to ~190s (#249); a chunk-restricted first-meta join 4579 * scans the whole meta key (365ms vs 15ms, #247). Every other postmeta lookup 4580 * by post_id — filter joins, variation child predicates, description / SKU 4581 * searches — takes its table reference from sql_postmeta_by_post_id(). 4582 * 4583 * Why only when present: FORCE INDEX naming a missing key is a hard SQL 4584 * error, and the main products query has no error signal — WP_Query would 4585 * return an empty page, a zero total and an empty matching-id list. The key is 4586 * not guaranteed: "Index WP MySQL For Speed" replaces postmeta's keys with a 4587 * (post_id, meta_key, meta_id) primary key and drops `post_id`. An unpinned 4588 * read is the plan every query had before the hint, so every doubt reads as ''. 4589 * A listed key is not enough either: an INVISIBLE key (MySQL 8) or an IGNORED 4590 * one (MariaDB 10.6+) still appears in SHOW INDEX, but a hint naming it is the 4591 * same "doesn't exist" error — see post_id_key_is_usable(). 4592 * 4593 * Detection reads schema metadata (SHOW INDEX), not product data, so the WC 4594 * CRUD rule does not apply. It goes through run_chunk_query(), whose query() 4595 * return check is the only failure signal to trust (a `query` filter that 4596 * empties the SQL leaves last_result stale). Inside a service operation it 4597 * runs once per table and the result ("no such key" and a failed read both 4598 * cached as false) ends with the operation; outside one nothing is kept, so 4599 * an entry point that forgot its scope costs a SHOW INDEX per call, never a 4600 * stale answer. 4601 * 4602 * Table and connection both come from the global $wpdb — never from a caller's 4603 * instance: every pinned read runs on the global connection (the main query 4604 * through WP_Query, child ordering through run_chunk_query()), and so does the 4605 * detection, so the key is always checked on the table and connection the 4606 * hinted SQL will actually hit. 4607 * 4608 * `wcabe_g2_sql_force_postmeta_index` can only DISABLE the hint: it is applied 4609 * on every call, and the hint needs the detected key AND the filter returning 4610 * exactly true, so no callback can pin a key that is not there. 4611 */ 4612 private static function postmeta_post_id_index_hint(): string { 4613 global $wpdb; 4614 $table = (string) $wpdb->postmeta; 4615 4616 /** 4617 * Filters whether postmeta reads may be pinned to WP core's `post_id` key. 4618 * 4619 * Disable-only: anything but true removes the hint; true has no effect 4620 * when the key was not detected on the table. 4621 * 4622 * @param bool $allow Default true. 4623 * @param string $table The postmeta table name. 4624 */ 4625 if ( true !== apply_filters( 'wcabe_g2_sql_force_postmeta_index', true, $table ) ) { 4626 return ''; 4627 } 4628 4629 if ( ! isset( self::$postmeta_post_id_index[ $table ] ) ) { 4630 // Seeded before the query, so a re-entrant call made while it runs reads 4631 // "no key" (unpinned) instead of detecting again. 4632 self::$postmeta_post_id_index[ $table ] = false; 4633 4634 // The table name is $wpdb's own property, not input. 4635 $rows = self::run_chunk_query( 4636 'SHOW INDEX FROM ' . self::sql_quote_table_name( $table ) . " WHERE Key_name = 'post_id'", 4637 'Could not read the postmeta indexes; postmeta reads run without an index hint.' 4638 ); 4639 4640 self::$postmeta_post_id_index[ $table ] = self::post_id_key_is_usable( $rows ); 4641 } 4642 4643 $usable = self::$postmeta_post_id_index[ $table ]; 4644 if ( 0 === self::$postmeta_index_scope_depth ) { 4645 unset( self::$postmeta_post_id_index[ $table ] ); 4646 } 4647 4648 return $usable ? ' FORCE INDEX (post_id)' : ''; 4649 } 4650 4651 /** 4652 * `{$wpdb->postmeta} AS $alias`, pinned to the `post_id` key where 4653 * postmeta_post_id_index_hint() allows it — the table reference for every 4654 * postmeta row looked up by `$alias.post_id = <a row's id>` (the filter joins, 4655 * the variation child predicates, the variation description / SKU searches). 4656 * 4657 * Why all of them, not only the #249 price probe: stale InnoDB statistics 4658 * can move ANY such lookup onto the meta_key index, and then every probe 4659 * scans the whole meta key — rows × key rows, the ~190s page of #249. Pinned, 4660 * a lookup costs one post_id probe per row, the same order as the scan the 4661 * query already makes over those rows. The plan the pin rules out — driving 4662 * an INNER JOIN from a RARE meta key — saves at most that same order of 4663 * probes, so the trade is bounded either way. Not for a join on meta_id (the 4664 * primary key: sql_join_first_meta()'s outer join) nor for a scan of a whole 4665 * meta key (its unrestricted grouped form), where post_id is the wrong key. 4666 * 4667 * Public because extension filters build the same kind of lookup from a 4668 * `posts_where` hook on the products query — Pro's custom meta "is empty" / 4669 * "is not empty" EXISTS probe (#141). Called there, it runs inside the 4670 * detection scope get_products() / stream_products() already hold. 4671 */ 4672 public static function sql_postmeta_by_post_id( string $alias ): string { 4673 global $wpdb; 4674 return "{$wpdb->postmeta} AS {$alias}" . self::postmeta_post_id_index_hint(); 4675 } 4676 4677 /** 4678 * `$table` as a quoted SQL identifier with one backtick-quoted part per dotted 4679 * segment, so a database-qualified name (`otherdb.wp_postmeta`, as some hosts 4680 * set `$wpdb->postmeta`) names that table rather than one literally called 4681 * "otherdb.wp_postmeta" — a failed detection, and the hint silently gone. 4682 * A part that arrives already backtick-quoted is unwrapped first. 4683 */ 4684 private static function sql_quote_table_name( string $table ): string { 4685 return implode( 4686 '.', 4687 array_map( 4688 static fn( string $part ): string => '`' . str_replace( '`', '``', trim( $part, '`' ) ) . '`', 4689 explode( '.', $table ) 4690 ) 4691 ); 4692 } 4693 4694 /** 4695 * Whether the SHOW INDEX rows of the `post_id` key describe a key an index 4696 * hint may name. The key must be listed, and every row of it (one per 4697 * column) must be neither INVISIBLE (MySQL 8 `Visible`) nor IGNORED (MariaDB 4698 * 10.6+ `Ignored`): FORCE INDEX on such a key fails exactly like a missing one. 4699 * A server that reports neither column (MySQL 5.7, older MariaDB) has neither 4700 * state. Column names are matched case-insensitively, and any value other than 4701 * the usable one reads as unusable. 4702 * 4703 * @param array<int, object>|null $rows run_chunk_query()'s result; null = failed read. 4704 */ 4705 private static function post_id_key_is_usable( ?array $rows ): bool { 4706 if ( empty( $rows ) ) { 4707 return false; 4708 } 4709 4710 foreach ( $rows as $row ) { 4711 $columns = array_change_key_case( (array) $row, CASE_LOWER ); 4712 if ( array_key_exists( 'visible', $columns ) && 'YES' !== strtoupper( (string) $columns['visible'] ) ) { 4713 return false; 4714 } 4715 if ( array_key_exists( 'ignored', $columns ) && 'NO' !== strtoupper( (string) $columns['ignored'] ) ) { 4716 return false; 4717 } 4718 } 4719 4720 return true; 4721 } 4722 4723 /** 4065 4724 * LEFT JOIN fragment resolving ONE postmeta row per product for a meta key, 4066 4725 * under alias `$alias` (a wp_postmeta row: `$alias.meta_value`). … … 4081 4740 * @return string 4082 4741 */ 4083 private static function sql_join_first_meta( \wpdb $wpdb, string $meta_key, string $alias ): string {4742 private static function sql_join_first_meta( \wpdb $wpdb, string $meta_key, string $alias, string $on = '', ?array $ids = null ): string { 4084 4743 $meta_key = esc_sql( $meta_key ); 4085 4086 return " LEFT JOIN ( SELECT d.post_id, MIN( d.meta_id ) AS meta_id FROM {$wpdb->postmeta} d" 4087 . " WHERE d.meta_key = '{$meta_key}' GROUP BY d.post_id ) {$alias}_d ON ( {$alias}_d.post_id = {$wpdb->posts}.ID )" 4744 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4745 $restrict = self::sql_id_restriction( 'd.post_id', $ids ); 4746 // Restricted (chunk-scoped) form: MySQL 8 still picks the meta_key index 4747 // for a several-hundred-id IN list and scans every row of the key 4748 // (measured 365ms vs 15ms at 120k rows of `_stock`), so pin WP core's 4749 // `post_id` key — only where it exists (postmeta_post_id_index_hint()). 4750 $hint = '' === $restrict ? '' : self::postmeta_post_id_index_hint(); 4751 4752 return " LEFT JOIN ( SELECT d.post_id, MIN( d.meta_id ) AS meta_id FROM {$wpdb->postmeta} d{$hint}" 4753 . " WHERE d.meta_key = '{$meta_key}'{$restrict} GROUP BY d.post_id ) {$alias}_d ON ( {$alias}_d.post_id = {$on} )" 4088 4754 . " LEFT JOIN {$wpdb->postmeta} {$alias} ON ( {$alias}.meta_id = {$alias}_d.meta_id ) "; 4755 } 4756 4757 /** 4758 * The id restriction for a grouped derived table: '' for null (unrestricted — 4759 * the catalogue-wide main query and the parent aggregate), ` AND {$col} IN ( … )` 4760 * for a non-empty list, and ` AND 1 = 0` for a list with no positive id — 4761 * "restricted to nothing", never silently widened to the whole catalogue 4762 * (child ordering, #247, keys every chunk-scoped join on this). Values are 4763 * cast to int here, so callers never interpolate raw input. 4764 * 4765 * @param int[]|null $ids 4766 */ 4767 private static function sql_id_restriction( string $col, ?array $ids ): string { 4768 return null === $ids ? '' : ' AND ' . self::sql_id_in( $col, $ids ); 4769 } 4770 4771 /** 4772 * `$col IN ( … )` over the positive ids in $ids, or `1 = 0` when there is 4773 * none — the predicate sql_id_restriction() appends. Values are cast to int. 4774 * 4775 * @param int[] $ids 4776 */ 4777 private static function sql_id_in( string $col, array $ids ): string { 4778 $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), static fn( int $id ): bool => $id > 0 ) ) ); 4779 return empty( $ids ) ? '1 = 0' : "{$col} IN ( " . implode( ',', $ids ) . ' )'; 4780 } 4781 4782 /** 4783 * The parent-side restriction for a chunk's child ordering query (#247): the 4784 * REAL parents of the chunk's variations, resolved in SQL from `post_parent`, 4785 * not the map keys the children were listed under — a `woocommerce_get_children` 4786 * filter may list a child under a parent that is not its own, and the grid 4787 * shows that child's real parent data. Callers pass the returned list as the 4788 * `$ids` of the parent-side sql_join_first_meta() joins; an empty list (no 4789 * child has a real parent) restricts those joins to nothing. 4790 * 4791 * Null when the lookup failed. Read through query()'s return value, never 4792 * get_col(): wpdb returns false before flush() when the `query` filter empties 4793 * the SQL, and get_col() would then read the PREVIOUS query's stale result as 4794 * the parent set. 4795 * 4796 * @param int[] $child_ids 4797 * @return int[]|null 4798 */ 4799 private static function real_parent_ids( \wpdb $wpdb, array $child_ids ): ?array { 4800 $list = implode( ',', array_map( 'intval', $child_ids ) ); 4801 if ( '' === $list ) { 4802 return []; 4803 } 4804 // Int-cast id list; a per-chunk PK read. The leading comment is a stable 4805 // marker for the failure-injection test. 4806 $rows = self::run_chunk_query( 4807 "SELECT /* wcabe_g2_order_variations_parents */ DISTINCT post_parent FROM {$wpdb->posts} WHERE ID IN ({$list}) AND post_parent > 0", 4808 'variation parent lookup failed; keeping the stored child order for this chunk.' 4809 ); 4810 return null === $rows ? null : array_map( static fn( $row ): int => (int) $row->post_parent, $rows ); 4811 } 4812 4813 /** 4814 * Run one chunked variation read — the #218 pre-filter, the #247 parent 4815 * lookup, child ordering and name ordering, the #276 stock-status 4816 * pre-read — and return its rows, or null when it failed. Public for 4817 * Operations_Service's duplicate SKU / slug reads, 4818 * which need the same failure signal. postmeta_post_id_index_hint() (#249) 4819 * reads null as "no post_id key": the one reading whose wrong direction 4820 * would empty the grid, so a contract change must keep it safe too. 4821 * 4822 * query()'s return value is the ONLY failure signal to trust: 4823 * it is false on every failure path (SQL error, emptied by the `query` filter, 4824 * connection not ready), and wpdb returns false BEFORE flush() when the filter 4825 * empties the SQL, leaving last_error '' and last_result stale — so neither of 4826 * those, nor get_col(), may be read instead. Error printing is suppressed for 4827 * the call and restored on every exit path, including a `query` filter that 4828 * throws (the exception still propagates). A failure is logged under WP_DEBUG; 4829 * the caller takes the fallback $on_error describes. 4830 * 4831 * @param string $sql Complete SQL: $wpdb->prepare() output, or built from 4832 * int-cast ids and this class's own fragments — see each 4833 * call site's note. 4834 * @param string $on_error What failed and the fallback the caller takes. 4835 * @return object[]|null 4836 */ 4837 public static function run_chunk_query( string $sql, string $on_error ): ?array { 4838 global $wpdb; 4839 4840 $was_suppressed = $wpdb->suppress_errors( true ); 4841 try { 4842 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- every caller passes prepare() output or SQL built from int-cast ids and this class's own fragments (see each call site); per-request chunked reads, nothing to cache. 4843 $result = $wpdb->query( $sql ); 4844 } finally { 4845 $wpdb->suppress_errors( $was_suppressed ); 4846 } 4847 4848 if ( false === $result ) { 4849 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'wc_get_logger' ) ) { 4850 wc_get_logger()->warning( 'WCABE G2: ' . $on_error . ' ' . $wpdb->last_error, [ 'source' => 'wcabe-g2' ] ); 4851 } 4852 return null; 4853 } 4854 return (array) $wpdb->last_result; 4089 4855 } 4090 4856 … … 4100 4866 * @return string 4101 4867 */ 4102 private static function sql_join_first_term( \wpdb $wpdb, string $taxonomy, string $alias ): string {4868 private static function sql_join_first_term( \wpdb $wpdb, string $taxonomy, string $alias, string $on = '', ?array $ids = null ): string { 4103 4869 $taxonomy = esc_sql( $taxonomy ); 4870 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4871 $restrict = self::sql_id_restriction( 'tr.object_id', $ids ); 4104 4872 4105 4873 return " LEFT JOIN ( SELECT tr.object_id, MIN( t.slug ) AS slug, MIN( t.name ) AS name" … … 4107 4875 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 4108 4876 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 4109 . " WHERE tt.taxonomy = '{$taxonomy}' GROUP BY tr.object_id ) {$alias} ON ( {$alias}.object_id = {$wpdb->posts}.ID) ";4877 . " WHERE tt.taxonomy = '{$taxonomy}'{$restrict} GROUP BY tr.object_id ) {$alias} ON ( {$alias}.object_id = {$on} ) "; 4110 4878 } 4111 4879 … … 4118 4886 * @return string 4119 4887 */ 4120 private static function sql_join_visibility_flags( \wpdb $wpdb ): string { 4888 private static function sql_join_visibility_flags( \wpdb $wpdb, string $on = '', ?array $ids = null ): string { 4889 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4890 $restrict = self::sql_id_restriction( 'tr.object_id', $ids ); 4891 4121 4892 return " LEFT JOIN ( SELECT tr.object_id," 4122 4893 . " MAX( t.slug = 'featured' ) AS featured," … … 4126 4897 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 4127 4898 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 4128 . " WHERE tt.taxonomy = 'product_visibility' GROUP BY tr.object_id ) wcabe_sort_vis ON ( wcabe_sort_vis.object_id = {$wpdb->posts}.ID) ";4899 . " WHERE tt.taxonomy = 'product_visibility'{$restrict} GROUP BY tr.object_id ) wcabe_sort_vis ON ( wcabe_sort_vis.object_id = {$on} ) "; 4129 4900 } 4130 4901 … … 4137 4908 private const SORT_TYPE_ALIAS = 'wcabe_sort_ptype'; 4138 4909 4139 private static function sql_join_product_type( \wpdb $wpdb ): string { 4140 return self::sql_join_first_term( $wpdb, 'product_type', self::SORT_TYPE_ALIAS ); 4910 private static function sql_join_product_type( \wpdb $wpdb, string $on = '', ?array $ids = null ): string { 4911 return self::sql_join_first_term( $wpdb, 'product_type', self::SORT_TYPE_ALIAS, $on, $ids ); 4912 } 4913 4914 /** 4915 * The `( $wpdb, $ctx )` closure context resolved to the row id expression 4916 * and the id restriction the join helpers take. `$ctx` is the array 4917 * resolve_sort_expressions() hands to every spec closure: `row` (table 4918 * alias of the row being ranked) and `ids` (int[]|null — null unrestricted, 4919 * a list restricts, an empty list matches nothing; see sql_id_restriction()). 4920 * Closures that predate #247 declare `( \wpdb $wpdb )` only and never see 4921 * it — fine for a parent-row sort, which is why third-party specs must opt 4922 * in to `child`. 4923 * 4924 * @param array<string, mixed> $ctx 4925 * @return array{0: string, 1: int[]|null} [ on-expression, ids ] 4926 */ 4927 private static function sort_ctx_join_args( \wpdb $wpdb, array $ctx ): array { 4928 $row = (string) ( $ctx['row'] ?? '' ); 4929 $on = '' === $row ? "{$wpdb->posts}.ID" : "{$row}.ID"; 4930 $ids = $ctx['ids'] ?? null; 4931 return [ $on, null === $ids ? null : (array) $ids ]; 4141 4932 } 4142 4933 … … 4194 4985 * (variable parents carry one `_price` row per child) nor from the 4195 4986 * lookup (an empty `_price` coerces to 0.0000, never NULL). 4987 * - child How a VARIATION row's displayed value on this column is derived 4988 * (#247). Feeds BOTH the parent aggregate below and the ordering of 4989 * variations under their parent — per-row form 4990 * child_sort_expressions(), aggregate form 4991 * child_aggregate_expressions(): the same displayed-value 4992 * semantics, each called directly by its consumer. 4993 * 'same' — the spec's own expressions evaluated on the 4994 * variation row (post / lookup / meta / expr). 4995 * 'none' — the grid shows nothing for variations on this 4996 * column: children order by ID in the sort 4997 * direction, no query, no aggregate. 4998 * 'display_name' — ranked on the formatted `name` 4999 * (variation_display_name(): parent title + 5000 * attribute labels, NOT the stored post_title) 5001 * by a literal-list query under the 5002 * `post_title` collation the parent rows sort 5003 * by (order_formatted_children()). 5004 * [ 'inherit' => 'parent_meta', 'meta' => '_weight' ] 5005 * — own meta unless it formats empty() through 5006 * wc_format_decimal() (sql_formats_empty()), 5007 * then the parent's (WC's view-context getters 5008 * for weight / dimensions). 5009 * [ 'inherit' => 'managed_stock', 'meta' => '_stock' | '_backorders', 5010 * 'type' => 'stock_qty' | 'string' ] 5011 * — the parent's value while the child does not 5012 * manage stock and the parent does (WC's 5013 * get_stock_quantity() / get_backorders()). 5014 * Parent-side reads use sql_join_first_meta() on c.post_parent — 5015 * never a plain postmeta join: a duplicate parent row would 5016 * multiply the child rows and hand one child two ranks. A spec 5017 * WITHOUT `child` (third-party) keeps parent-only ranking and WC's 5018 * child order — its closures may hard-code the posts table. 5019 * Closures receive `( $wpdb, $ctx )`; see sort_ctx_join_args(). 5020 * - aggregate 5021 * bool, default true whenever `child` is 'same' or an inherit array 5022 * (never for 'none' / 'display_name'). A VARIABLE parent then ranks 5023 * by the family extreme: MIN (asc) / MAX (desc) over its own 5024 * non-blank value and its variations' non-blank displayed values; 5025 * blank only when the whole family is blank. Gated on the product 5026 * type so leftover variation posts under a converted simple product 5027 * contribute nothing. Aggregates over every publish/private child, 5028 * not the filter-narrowed set (accepted approximation). 4196 5029 * 4197 5030 * Blank rows sort LAST in both directions, mirroring the grid's client-side … … 4225 5058 $specs = [ 4226 5059 // wp_posts columns. 4227 'id' => [ 'kind' => 'post', 'key' => 'ID', 'type' => 'numeric_native' ], 4228 'menu_order' => [ 'kind' => 'post', 'key' => 'menu_order', 'type' => 'numeric_native' ], 4229 'name' => [ 'kind' => 'post', 'key' => 'post_title', 'type' => 'string' ], 4230 'slug' => [ 'kind' => 'post', 'key' => 'post_name', 'type' => 'string' ], 4231 'status' => [ 'kind' => 'post', 'key' => 'post_status', 'type' => 'string' ], 4232 'date_created' => [ 'kind' => 'post', 'key' => 'post_date', 'type' => 'string' ], 4233 'date_modified' => [ 'kind' => 'post', 'key' => 'post_modified', 'type' => 'string' ], 5060 // id / slug / status: variations carry them (child 'same') but a family 5061 // extreme over ids or slugs is meaningless, so no aggregate. id children 5062 // rank by ID in PHP, no query (spec_ranks_children_by_id()). name: the 5063 // grid shows variation_display_name(), not the stored post_title (WC may 5064 // omit the attributes from it), so children are ranked in PHP on the 5065 // formatted name. The two dates are parent-only in format_variation(). 5066 'id' => [ 'kind' => 'post', 'key' => 'ID', 'type' => 'numeric_native', 'child' => 'same', 'aggregate' => false ], 5067 // menu_order: a variation's menu_order is its position WITHIN the parent, 5068 // so a family extreme would be meaningless — children are ordered, the 5069 // parent ranks by its own value. 5070 'menu_order' => [ 'kind' => 'post', 'key' => 'menu_order', 'type' => 'numeric_native', 'child' => 'same', 'aggregate' => false ], 5071 'name' => [ 'kind' => 'post', 'key' => 'post_title', 'type' => 'string', 'child' => 'display_name' ], 5072 'slug' => [ 'kind' => 'post', 'key' => 'post_name', 'type' => 'string', 'child' => 'same', 'aggregate' => false ], 5073 'status' => [ 'kind' => 'post', 'key' => 'post_status', 'type' => 'string', 'child' => 'same', 'aggregate' => false ], 5074 'date_created' => [ 'kind' => 'post', 'key' => 'post_date', 'type' => 'string', 'child' => 'none' ], 5075 'date_modified' => [ 'kind' => 'post', 'key' => 'post_modified', 'type' => 'string', 'child' => 'none' ], 4234 5076 4235 5077 // wc_product_meta_lookup columns (one row per product, PK product_id). … … 4237 5079 // are both SKU-less; lexical ordering under the table collation (typically 4238 5080 // *_ci) puts letters above digits on a descending sort — Woo-native (#189). 4239 'sku' => [ 'kind' => 'lookup', 'key' => 'sku', 'type' => 'string' ], 5081 'sku' => [ 'kind' => 'lookup', 'key' => 'sku', 'type' => 'string', 'child' => 'same' ], 5082 // global_unique_id (GTIN, #250): varchar NULL default '', written from the 5083 // product's own _global_unique_id (variations never inherit it). Same 5084 // shape as sku, aggregate included: GTINs usually sit on the variations, 5085 // so a variable parent without its own ranks by its family's extreme 5086 // instead of sinking to the blanks. 5087 'global_unique_id' => [ 'kind' => 'lookup', 'key' => 'global_unique_id', 'type' => 'string', 'child' => 'same' ], 4240 5088 // stock_quantity is NULL when stock is not managed — blank, matching the 4241 5089 // "Varies"/empty cell. virtual/downloadable are tinyint 0/1. 4242 'stock_quantity' => [ 'kind' => 'lookup', 'key' => 'stock_quantity', 'type' => 'numeric_native' ], 4243 'stock_status' => [ 'kind' => 'lookup', 'key' => 'stock_status', 'type' => 'string' ], 4244 'virtual' => [ 'kind' => 'lookup', 'key' => 'virtual', 'type' => 'numeric_native' ], 4245 'downloadable' => [ 'kind' => 'lookup', 'key' => 'downloadable', 'type' => 'numeric_native' ], 5090 // stock_quantity: a variation row DISPLAYS the parent's quantity while it 5091 // inherits stock management (WC_Product_Variation::get_stock_quantity(), 5092 // view context) — the lookup row holds only its own _stock, so the child 5093 // branch reads postmeta with the inheritance rule instead. 5094 'stock_quantity' => [ 'kind' => 'lookup', 'key' => 'stock_quantity', 'type' => 'numeric_native', 'child' => [ 'inherit' => 'managed_stock', 'meta' => '_stock', 'type' => 'stock_qty' ] ], 5095 'stock_status' => [ 'kind' => 'lookup', 'key' => 'stock_status', 'type' => 'string', 'child' => 'same' ], 5096 // virtual / downloadable: WooCommerce ignores a variable parent's own flag 5097 // (always unchecked, not editable), so the guard blanks it and a variable 5098 // parent ranks by its variations only (#247); one with no variations is 5099 // blank. 5100 'virtual' => [ 'kind' => 'lookup', 'key' => 'virtual', 'type' => 'numeric_native', 'guard' => 'not_variable', 'child' => 'same' ], 5101 'downloadable' => [ 'kind' => 'lookup', 'key' => 'downloadable', 'type' => 'numeric_native', 'guard' => 'not_variable', 'child' => 'same' ], 4246 5102 // Price — mirrors WooCommerce core's order_by_price_{asc,desc}_post_clauses(). 4247 5103 // Ascending ranks a variable parent by its cheapest child (min_price), … … 4251 5107 // NULL. EXISTS is multi-row safe (variable parents carry one `_price` row 4252 5108 // per child) and keeps a literal '0' price a real price. 5109 // 5110 // Children (#247): the same expressions on the variation's own lookup row 5111 // rank variations by their EFFECTIVE (sale-aware `_price`) price while 5112 // the cell shows the regular price — the one documented exception to 5113 // "children sort by what the grid displays", inherited from #184 and 5114 // deliberately not widened. No aggregate: min_price / max_price on the 5115 // parent row already ARE the children's extremes (WC syncs them). 4253 5116 'regular_price' => [ 4254 'kind' => 'lookup', 4255 'key' => 'min_price', 4256 'type' => 'numeric_native', 4257 'key_asc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.min_price', 4258 'key_desc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.max_price', 4259 'blank' => static fn( \wpdb $wpdb ): string => "NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wcabe_price_probe" 4260 . " WHERE wcabe_price_probe.post_id = {$wpdb->posts}.ID" 4261 . " AND wcabe_price_probe.meta_key = '_price'" 4262 . " AND wcabe_price_probe.meta_value <> '' )", 5117 'kind' => 'lookup', 5118 'key' => 'min_price', 5119 'type' => 'numeric_native', 5120 'child' => 'same', 5121 'aggregate' => false, 5122 'key_asc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.min_price', 5123 'key_desc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.max_price', 5124 // The probe is pinned to the post_id key where it exists: under stale 5125 // statistics MySQL moved it to meta_key and a page took ~190s (#249). 5126 'blank' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5127 [ $on ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5128 $hint = self::postmeta_post_id_index_hint(); 5129 return "NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wcabe_price_probe{$hint}" 5130 . " WHERE wcabe_price_probe.post_id = {$on}" 5131 . " AND wcabe_price_probe.meta_key = '_price'" 5132 . " AND wcabe_price_probe.meta_value <> '' )"; 5133 }, 4263 5134 ], 4264 5135 … … 4268 5139 // _sale_price of their own (a stale one can survive a type change), so 4269 5140 // they sort as blank rather than by that stale value. 4270 'sale_price' => [ 'kind' => 'meta', 'key' => '_sale_price', 'type' => 'numeric_text', 'guard' => 'not_variable' ], 4271 'weight' => [ 'kind' => 'meta', 'key' => '_weight', 'type' => 'numeric_text' ], 4272 'length' => [ 'kind' => 'meta', 'key' => '_length', 'type' => 'numeric_text' ], 4273 'width' => [ 'kind' => 'meta', 'key' => '_width', 'type' => 'numeric_text' ], 4274 'height' => [ 'kind' => 'meta', 'key' => '_height', 'type' => 'numeric_text' ], 5141 // The variable parent then ranks by its children's sale prices (#247). 5142 'sale_price' => [ 'kind' => 'meta', 'key' => '_sale_price', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 5143 // Weight / dimensions: a variation DISPLAYS the parent's value while its 5144 // own, as wc_format_decimal() reads it, is empty() — see 5145 // sql_formats_empty() (WC_Product_Variation::get_weight()). 5146 'weight' => [ 'kind' => 'meta', 'key' => '_weight', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_weight' ] ], 5147 'length' => [ 'kind' => 'meta', 'key' => '_length', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_length' ] ], 5148 'width' => [ 'kind' => 'meta', 'key' => '_width', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_width' ] ], 5149 'height' => [ 'kind' => 'meta', 'key' => '_height', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_height' ] ], 4275 5150 // Sale dates are stored as UNIX timestamps; format_product() blanks them 4276 5151 // on variable parents, so the guard does the same. 4277 'date_on_sale_from' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_from', 'type' => 'numeric_text', 'guard' => 'not_variable' ],4278 'date_on_sale_to' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_to', 'type' => 'numeric_text', 'guard' => 'not_variable' ],5152 'date_on_sale_from' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_from', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 5153 'date_on_sale_to' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_to', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 4279 5154 4280 5155 // postmeta strings. Both read as 'no' when the row is missing (WC's 4281 5156 // getters default; format_product() renders an unchecked box / "Do not 4282 5157 // allow"), so a meta-less product ranks with the explicit 'no' rows. 4283 'backorders' => [ 'kind' => 'meta', 'key' => '_backorders', 'type' => 'string', 'default' => 'no' ], 4284 'manage_stock' => [ 'kind' => 'meta', 'key' => '_manage_stock', 'type' => 'string', 'default' => 'no' ], 5158 // backorders: a variation DISPLAYS the parent's value while it inherits 5159 // stock management (get_backorders(), view context); manage_stock is 5160 // emitted in edit context — the variation's own flag. 5161 'backorders' => [ 'kind' => 'meta', 'key' => '_backorders', 'type' => 'string', 'default' => 'no', 'child' => [ 'inherit' => 'managed_stock', 'meta' => '_backorders', 'type' => 'string' ] ], 5162 'manage_stock' => [ 'kind' => 'meta', 'key' => '_manage_stock', 'type' => 'string', 'default' => 'no', 'child' => 'same' ], 4285 5163 // External-only fields: format_product() emits '' on every other type. 4286 'button_text' => [ 'kind' => 'meta', 'key' => '_button_text', 'type' => 'string', 'guard' => 'external' ],4287 'product_url' => [ 'kind' => 'meta', 'key' => '_product_url', 'type' => 'string', 'guard' => 'external' ],5164 'button_text' => [ 'kind' => 'meta', 'key' => '_button_text', 'type' => 'string', 'guard' => 'external', 'child' => 'none' ], 5165 'product_url' => [ 'kind' => 'meta', 'key' => '_product_url', 'type' => 'string', 'guard' => 'external', 'child' => 'none' ], 4288 5166 4289 5167 // Taxonomy-backed and derived columns. … … 4293 5171 'key' => static fn( \wpdb $wpdb ): string => self::sql_product_type_expr(), 4294 5172 'type' => 'string', 5173 'child' => 'none', 4295 5174 ], 4296 5175 // Sorted by the class NAME the grid displays, not the stored slug. 5176 // A variation's OWN class ('' = same as parent renders blank — #247 5177 // ranks the inheriting child as blank, as the cell shows). 4297 5178 'shipping_class' => [ 4298 5179 'kind' => 'expr', 4299 'joins' => static fn( \wpdb $wpdb ): string => self::sql_join_first_term( $wpdb, 'product_shipping_class', 'wcabe_sort_ship' ), 5180 'joins' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5181 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5182 return self::sql_join_first_term( $wpdb, 'product_shipping_class', 'wcabe_sort_ship', $on, $ids ); 5183 }, 4300 5184 'key' => static fn( \wpdb $wpdb ): string => 'wcabe_sort_ship.name', 4301 5185 'type' => 'string', 5186 'child' => 'same', 4302 5187 ], 4303 5188 // 0/1 flag; a product with no visibility term at all is simply not featured. … … 4307 5192 'key' => static fn( \wpdb $wpdb ): string => 'COALESCE( wcabe_sort_vis.featured, 0 )', 4308 5193 'type' => 'numeric_native', 5194 'child' => 'none', 4309 5195 ], 4310 5196 // WC encodes visibility as two exclusion terms: both → hidden, … … 4318 5204 . " ELSE 'visible' END )", 4319 5205 'type' => 'string', 5206 'child' => 'none', 4320 5207 ], 4321 5208 // Featured image upload date: the attachment's post_date. The post_type … … 4325 5212 'image_date' => [ 4326 5213 'kind' => 'expr', 4327 'joins' => static fn( \wpdb $wpdb ): string => self::sql_join_first_meta( $wpdb, '_thumbnail_id', 'wcabe_sort_img_meta' ) 4328 . " LEFT JOIN {$wpdb->posts} wcabe_sort_img ON ( wcabe_sort_img.ID = CAST( wcabe_sort_img_meta.meta_value AS UNSIGNED ) AND wcabe_sort_img.post_type = 'attachment' ) ", 5214 'joins' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5215 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5216 return self::sql_join_first_meta( $wpdb, '_thumbnail_id', 'wcabe_sort_img_meta', $on, $ids ) 5217 . " LEFT JOIN {$wpdb->posts} wcabe_sort_img ON ( wcabe_sort_img.ID = CAST( wcabe_sort_img_meta.meta_value AS UNSIGNED ) AND wcabe_sort_img.post_type = 'attachment' ) "; 5218 }, 4329 5219 'key' => static fn( \wpdb $wpdb ): string => 'wcabe_sort_img.post_date', 4330 5220 'type' => 'string', 5221 'child' => 'same', 4331 5222 ], 4332 5223 ]; … … 4335 5226 // render the column otherwise, and the REST enum rejects the key. 4336 5227 if ( Product_Helper::is_cogs_enabled() ) { 4337 $specs['cogs_value'] = [ 'kind' => 'meta', 'key' => '_cogs_total_value', 'type' => 'numeric_text' ];5228 $specs['cogs_value'] = [ 'kind' => 'meta', 'key' => '_cogs_total_value', 'type' => 'numeric_text', 'child' => 'same' ]; 4338 5229 } 4339 5230 … … 4362 5253 } 4363 5254 4364 private const SORT_KINDS = [ 'post', 'lookup', 'meta', 'expr' ]; 4365 private const SORT_TYPES = [ 'numeric_text', 'numeric_native', 'string' ]; 4366 private const SORT_GUARDS = [ 'not_variable', 'external' ]; 5255 private const SORT_KINDS = [ 'post', 'lookup', 'meta', 'expr' ]; 5256 private const SORT_TYPES = [ 'numeric_text', 'numeric_native', 'string' ]; 5257 private const SORT_GUARDS = [ 'not_variable', 'external' ]; 5258 private const SORT_CHILD_MODES = [ 'same', 'none', 'display_name' ]; 5259 private const SORT_CHILD_INHERIT = [ 'parent_meta', 'managed_stock' ]; 5260 private const SORT_CHILD_TYPES = [ 'stock_qty', 'string' ]; 4367 5261 4368 5262 /** … … 4411 5305 } 4412 5306 } 5307 if ( array_key_exists( 'child', $spec ) ) { 5308 $child = $spec['child']; 5309 if ( is_string( $child ) ) { 5310 if ( ! in_array( $child, self::SORT_CHILD_MODES, true ) ) { 5311 return 'child must be one of ' . implode( '|', self::SORT_CHILD_MODES ) . ' or an inherit array'; 5312 } 5313 } elseif ( is_array( $child ) ) { 5314 if ( ! in_array( $child['inherit'] ?? null, self::SORT_CHILD_INHERIT, true ) ) { 5315 return 'child inherit must be one of ' . implode( '|', self::SORT_CHILD_INHERIT ); 5316 } 5317 if ( ! is_string( $child['meta'] ?? null ) || '' === $child['meta'] ) { 5318 return 'child meta must be a non-empty string'; 5319 } 5320 if ( 'managed_stock' === $child['inherit'] && ! in_array( $child['type'] ?? null, self::SORT_CHILD_TYPES, true ) ) { 5321 return 'child type must be one of ' . implode( '|', self::SORT_CHILD_TYPES ); 5322 } 5323 } else { 5324 return 'child must be a string mode or an inherit array'; 5325 } 5326 } 5327 if ( array_key_exists( 'aggregate', $spec ) ) { 5328 if ( ! is_bool( $spec['aggregate'] ) ) { 5329 return 'aggregate must be a bool'; 5330 } 5331 if ( $spec['aggregate'] && ! self::spec_orders_children_in_sql( $spec ) ) { 5332 return "aggregate requires child 'same' or an inherit array"; 5333 } 5334 } 4413 5335 return null; 5336 } 5337 5338 /** 5339 * Whether a spec's variation rows are ranked by SQL (child 'same' or an 5340 * inherit array) — the precondition for the parent aggregate and for 5341 * order_children_by_sql(). 'none' and 'display_name' are ranked in PHP. 5342 */ 5343 private static function spec_orders_children_in_sql( array $spec ): bool { 5344 $child = $spec['child'] ?? null; 5345 return 'same' === $child || is_array( $child ); 5346 } 5347 5348 /** 5349 * Whether a spec ranks a variation row by nothing but its own ID (the `id` 5350 * column: post kind keyed on `ID`, no overrides). Such children are ordered 5351 * in PHP — the id lists are already in hand — instead of by a query whose 5352 * only effect would be ORDER BY c.ID (#247). Derived from the spec shape, 5353 * not the column id, so a filter that reshapes `id` is never mis-routed. 5354 */ 5355 private static function spec_ranks_children_by_id( array $spec ): bool { 5356 return 'same' === ( $spec['child'] ?? null ) 5357 && 'post' === ( $spec['kind'] ?? null ) 5358 && 'ID' === ( $spec['key'] ?? null ) 5359 && ! isset( $spec['key_asc'] ) 5360 && ! isset( $spec['key_desc'] ) 5361 && ! isset( $spec['blank'] ); 5362 } 5363 5364 /** 5365 * Whether the parent row ranks by the family extreme (#247). 5366 */ 5367 private static function spec_aggregates( array $spec ): bool { 5368 return ( $spec['aggregate'] ?? true ) && self::spec_orders_children_in_sql( $spec ); 5369 } 5370 5371 /** 5372 * WC's wc_string_to_bool() truthy set as a SQL predicate over a `_manage_stock` 5373 * meta column: strtolower(), then byte-exact 'yes' / 'true' / '1'; a missing 5374 * row reads as 'no'. WC itself only ever writes 'yes', but a third-party 5375 * writer storing '1' / 'true' must resolve the same way everywhere this is 5376 * consulted (the variation pre-filter, #218; child sort inheritance, #247). 5377 * 5378 * @param string $col SQL column expression. 5379 * @param bool $exact Byte-exact (CAST AS BINARY) comparison; false keeps the 5380 * collation-dependent form the surface-mode EXISTS relies on. 5381 */ 5382 private static function sql_manages_stock( string $col, bool $exact ): string { 5383 return $exact 5384 ? "( CAST( LOWER( COALESCE( {$col}, 'no' ) ) AS BINARY ) IN ( 'yes', '1', 'true' ) )" 5385 : "( COALESCE( {$col}, 'no' ) IN ( 'yes', '1', 'true' ) )"; 5386 } 5387 5388 /** 5389 * Whether a raw weight / dimension meta value reads as empty() once 5390 * WooCommerce has formatted it — the condition under which a variation's 5391 * view-context getter (get_weight() & co.) shows the PARENT's value (#247). 5392 * WC reads the own value through set_weight() → wc_format_decimal(): 5393 * wc_clean(), then keep only [0-9.-] and drop every dot except a last one 5394 * that still has a character after it. The result is empty() when it is '' 5395 * (no digit, no '-', no surviving dot) or '0' (one '0' digit, nothing else 5396 * from [0-9-], no surviving dot). REGEXP only — REGEXP_REPLACE needs MySQL 8. 5397 * 5398 * "A character after the last dot" is judged after wc_clean(), which 5399 * collapses [\r\n\t ] runs and PHP-trim()s the value, so exactly space, \t, 5400 * \n, \r and \v do not count. The class spells them out: MySQL's [:space:] 5401 * also matches \f and (ICU) no-break space, which WordPress keeps. They are 5402 * raw bytes inside a plain string literal — no backslash escape (MySQL 5.7's 5403 * regex library reads none, and NO_BACKSLASH_ESCAPES would disable the 5404 * string ones) and no CHAR(): a CHAR( … USING utf8mb4 ) carries the 5405 * charset's default collation, and CONCAT()ed with a literal of another 5406 * collation it becomes utf8mb4_bin with no coercibility, an illegal mix 5407 * against the meta column. A lone literal is COERCIBLE, so the column's 5408 * collation always wins. 5409 * 5410 * Not modelled — each changes only which way the inherit decision goes: 5411 * - %XX octets: wc_clean() deletes them first ('0%20' → '0', '%30' → '' 5412 * inherit in WC); here their digits count, so the child keeps its value. 5413 * - Markup: wc_clean() strips tags first; here the markup is ordinary text, 5414 * so [0-9.-] inside a tag counts ('<i class="a-1">0</i>') and markup after 5415 * the last dot keeps that dot alive ('0.<b></b>' formats to '0' and 5416 * inherits in WC; here the child keeps its value). 5417 * - A decimal separator other than '.' (the price setting or the PHP 5418 * locale's): WC turns it into '.' first; here it is an ordinary stripped 5419 * character. Under ',' the values '0,kg' and ',a' keep their own value 5420 * in WC ('0.' / '.') but inherit here. 5421 * - A trailing NUL byte: PHP's trim() drops it, the class cannot carry it 5422 * (MySQL 5.7 compiles the pattern as a C string), so '0.\0' keeps its own 5423 * value here and inherits in WC. 5424 * 5425 * @param string $col SQL column expression holding the raw meta value. 5426 */ 5427 private static function sql_formats_empty( string $col ): string { 5428 $last_dot_survives = "'[.][^.]*[^. \t\n\v\r][^.]*\$'"; 5429 return "( {$col} IS NULL OR ( ( NOT {$col} REGEXP '[0-9-]' OR {$col} REGEXP '^[^0-9-]*0[^0-9-]*\$' )" 5430 . " AND NOT {$col} REGEXP {$last_dot_survives} ) )"; 4414 5431 } 4415 5432 … … 4421 5438 public static function get_sortable_columns(): array { 4422 5439 return array_values( array_unique( array_merge( [ 'id' ], array_keys( self::get_sort_specs() ) ) ) ); 5440 } 5441 5442 /** 5443 * Scope a posts_join / posts_where / posts_clauses callback to the ONE WP_Query 5444 * carrying `$token` under PRODUCTS_QUERY_VAR (see that constant). Every scoped 5445 * callback get_products_wc_crud() registers goes through here, so the guard 5446 * cannot be forgotten on the next one: the callbacks themselves stay guard-free 5447 * and receive only the clause they modify. 5448 * 5449 * @param string $token The invoking get_products_wc_crud() call's token. 5450 * @param callable $callback function( string|array $clause ): string|array 5451 * @return callable( string|array, \WP_Query ): string|array 5452 */ 5453 private static function scoped_to( string $token, callable $callback ): callable { 5454 return static function ( $clause, \WP_Query $query ) use ( $token, $callback ) { 5455 if ( $query->get( self::PRODUCTS_QUERY_VAR ) !== $token ) { 5456 return $clause; 5457 } 5458 return $callback( $clause ); 5459 }; 4423 5460 } 4424 5461 … … 4442 5479 * product_type join with the `type` spec). 4443 5480 * 5481 * The returned callback is NOT scoped to a query by itself — the caller wraps it 5482 * with scoped_to() like every other posts_* callback in get_products_wc_crud(). 5483 * 4444 5484 * @param array<string, mixed> $spec One get_sort_specs() entry. 4445 5485 * @param bool $asc Sort direction. 4446 * @return callable( array , \WP_Query): array5486 * @return callable( array ): array 4447 5487 */ 4448 5488 private static function build_sort_clauses( array $spec, bool $asc ): callable { 4449 return static function ( array $clauses , \WP_Query $query) use ( $spec, $asc ): array {5489 return static function ( array $clauses ) use ( $spec, $asc ): array { 4450 5490 global $wpdb; 4451 5491 4452 if ( ! $query->get( self::SORT_QUERY_VAR ) ) { 4453 return $clauses; 4454 } 4455 4456 $alias = self::SORT_LOOKUP_ALIAS; 4457 $dir = $asc ? 'ASC' : 'DESC'; 4458 $joins = []; 4459 4460 switch ( $spec['kind'] ) { 4461 case 'post': 4462 $raw = "{$wpdb->posts}." . $spec['key']; 4463 break; 4464 case 'lookup': 4465 $joins[ $alias ] = " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$alias}" 4466 . " ON {$wpdb->posts}.ID = {$alias}.product_id "; 4467 $raw = "{$alias}." . $spec['key']; 4468 break; 4469 case 'meta': 4470 $joins['wcabe_sort_pm'] = self::sql_join_first_meta( $wpdb, (string) $spec['key'], 'wcabe_sort_pm' ); 4471 $raw = 'wcabe_sort_pm.meta_value'; 4472 break; 4473 default: 4474 $raw = (string) call_user_func( $spec['key'], $wpdb ); 4475 if ( isset( $spec['joins'] ) ) { 4476 $fragment = (string) call_user_func( $spec['joins'], $wpdb ); 4477 // Keyed on the first alias in the fragment so a shared join 4478 // (visibility flags, product type) is added once. 4479 $joins[ self::first_join_alias( $fragment ) ] = $fragment; 4480 } 4481 break; 4482 } 4483 4484 if ( isset( $spec['default'] ) ) { 4485 $raw = "COALESCE( {$raw}, '" . esc_sql( (string) $spec['default'] ) . "' )"; 4486 } 4487 4488 switch ( $spec['type'] ) { 4489 case 'numeric_text': 4490 $key = "CAST( NULLIF( TRIM( {$raw} ), '' ) AS DECIMAL(24,8) )"; 4491 $blank = "NULLIF( TRIM( {$raw} ), '' ) IS NULL"; 4492 break; 4493 case 'numeric_native': 4494 $key = $raw; 4495 $blank = "{$raw} IS NULL"; 4496 break; 4497 default: 4498 $key = $raw; 4499 $blank = "COALESCE( {$raw}, '' ) = ''"; 4500 break; 4501 } 4502 4503 // Overrides FIRST, guard LAST: the guard wraps whatever key/blank the 4504 // spec ends up with, so a spec combining `guard` with `key_asc` / 4505 // `key_desc` / `blank` keeps the guard (the other order silently 4506 // dropped it — the override replaced the guarded key wholesale). 4507 $key_override = $asc ? ( $spec['key_asc'] ?? null ) : ( $spec['key_desc'] ?? null ); 4508 if ( null !== $key_override ) { 4509 $key = (string) call_user_func( $key_override, $wpdb ); 4510 } 4511 if ( isset( $spec['blank'] ) ) { 4512 $blank = (string) call_user_func( $spec['blank'], $wpdb ); 4513 } 4514 4515 if ( ! empty( $spec['guard'] ) ) { 4516 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb ); 4517 $type_expr = self::sql_product_type_expr(); 4518 $guard = ( 'external' === $spec['guard'] ) 4519 ? "{$type_expr} = 'external'" 4520 : "{$type_expr} NOT IN ( " . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 4521 $key = "( CASE WHEN {$guard} THEN {$key} ELSE NULL END )"; 4522 $blank = "( NOT ( {$guard} ) OR {$blank} )"; 5492 $dir = $asc ? 'ASC' : 'DESC'; 5493 5494 $own = self::resolve_sort_expressions( $spec, $asc, [ 'row' => '', 'ids' => null, 'guard' => true ] ); 5495 $key = $own['key']; 5496 $blank = $own['blank']; 5497 $joins = $own['joins']; 5498 5499 if ( self::spec_aggregates( $spec ) ) { 5500 // Family extreme (#247): a VARIABLE parent ranks by MIN (asc) / MAX 5501 // (desc) over its own non-blank value and its variations' non-blank 5502 // DISPLAYED values. The children are read through ONE grouped derived 5503 // table over every publish/private variation (≤ 1 row per parent, so 5504 // SQL_CALC_FOUND_ROWS cannot change), from the aggregate form of the 5505 // displayed-value expressions (child_aggregate_expressions()). 5506 $child = self::child_aggregate_expressions( $spec, $asc ); 5507 5508 // Gate on the parent's type: a variable→simple conversion can leave 5509 // published variation posts behind (see sql_is_variable_product()), 5510 // and a simple product must never rank by children it does not show. 5511 if ( ! isset( $joins[ self::SORT_TYPE_ALIAS ] ) ) { 5512 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb ); 5513 } 5514 $is_variable = self::sql_product_type_expr() . ' IN ( ' . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 5515 5516 // Keyed LITERALLY — first_join_alias() would pick the first inner 5517 // alias out of this text (e.g. `wcabe_sort_pm_d`) — and inserted AFTER 5518 // the own-row joins: the join de-dup below is a substring check, and 5519 // this fragment contains the outer aliases. 5520 $joins['wcabe_sort_child'] = ' LEFT JOIN ( SELECT c.post_parent,' 5521 . " MIN( CASE WHEN NOT ( {$child['blank']} ) THEN {$child['key']} END ) AS k_min," 5522 . " MAX( CASE WHEN NOT ( {$child['blank']} ) THEN {$child['key']} END ) AS k_max" 5523 . " FROM {$wpdb->posts} c" . implode( '', $child['joins'] ) 5524 . " WHERE c.post_type = 'product_variation' AND c.post_status IN ( 'publish', 'private' )" 5525 . " GROUP BY c.post_parent ) wcabe_sort_child ON ( wcabe_sort_child.post_parent = {$wpdb->posts}.ID ) "; 5526 5527 $agg = 'wcabe_sort_child.' . ( $asc ? 'k_min' : 'k_max' ); 5528 $k = "( CASE WHEN {$is_variable} THEN {$agg} END )"; 5529 $own_nb = "( CASE WHEN NOT ( {$blank} ) THEN {$key} END )"; 5530 $fn = $asc ? 'LEAST' : 'GREATEST'; 5531 // LEAST / GREATEST return NULL when either side is NULL, so the 5532 // COALESCE chain yields the extreme when both exist, the one present 5533 // otherwise, and NULL (blank) only when the whole family is blank. 5534 $key = "COALESCE( {$fn}( {$own_nb}, {$k} ), {$own_nb}, {$k} )"; 5535 $blank = "( {$own_nb} IS NULL AND {$k} IS NULL )"; 4523 5536 } 4524 5537 … … 4536 5549 return $clauses; 4537 5550 }; 5551 } 5552 5553 /** 5554 * One spec → the key / blank SQL and the JOINs they need, for the row named 5555 * by `$ctx['row']` (the posts table when '', or a variation alias such as 5556 * `c`). `$ctx['ids']` restricts the grouped derived tables to those ids so a 5557 * chunk-scoped query never groups the whole catalogue; `$ctx['guard']` applies 5558 * the display guards, which are only meaningful on the parent row. 5559 * 5560 * @param array<string, mixed> $spec 5561 * @param array{row?: string, ids?: int[]|null, guard?: bool} $ctx 5562 * @return array{key: string, blank: string, joins: array<string, string>} 5563 */ 5564 private static function resolve_sort_expressions( array $spec, bool $asc, array $ctx ): array { 5565 global $wpdb; 5566 5567 $row = '' === (string) ( $ctx['row'] ?? '' ) ? $wpdb->posts : (string) $ctx['row']; 5568 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5569 5570 $alias = self::SORT_LOOKUP_ALIAS; 5571 $joins = []; 5572 5573 switch ( $spec['kind'] ) { 5574 case 'post': 5575 $raw = "{$row}." . $spec['key']; 5576 break; 5577 case 'lookup': 5578 $joins[ $alias ] = " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$alias}" 5579 . " ON {$on} = {$alias}.product_id "; 5580 $raw = "{$alias}." . $spec['key']; 5581 break; 5582 case 'meta': 5583 $joins['wcabe_sort_pm'] = self::sql_join_first_meta( $wpdb, (string) $spec['key'], 'wcabe_sort_pm', $on, $ids ); 5584 $raw = 'wcabe_sort_pm.meta_value'; 5585 break; 5586 default: 5587 $raw = (string) call_user_func( $spec['key'], $wpdb, $ctx ); 5588 if ( isset( $spec['joins'] ) ) { 5589 $fragment = (string) call_user_func( $spec['joins'], $wpdb, $ctx ); 5590 // Keyed on the first alias in the fragment so a shared join 5591 // (visibility flags, product type) is added once. 5592 $joins[ self::first_join_alias( $fragment ) ] = $fragment; 5593 } 5594 break; 5595 } 5596 5597 if ( isset( $spec['default'] ) ) { 5598 $raw = "COALESCE( {$raw}, '" . esc_sql( (string) $spec['default'] ) . "' )"; 5599 } 5600 5601 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $raw ); 5602 5603 // Overrides FIRST, guard LAST: the guard wraps whatever key/blank the 5604 // spec ends up with, so a spec combining `guard` with `key_asc` / 5605 // `key_desc` / `blank` keeps the guard (the other order silently 5606 // dropped it — the override replaced the guarded key wholesale). 5607 $key_override = $asc ? ( $spec['key_asc'] ?? null ) : ( $spec['key_desc'] ?? null ); 5608 if ( null !== $key_override ) { 5609 $key = (string) call_user_func( $key_override, $wpdb, $ctx ); 5610 } 5611 if ( isset( $spec['blank'] ) ) { 5612 $blank = (string) call_user_func( $spec['blank'], $wpdb, $ctx ); 5613 } 5614 5615 if ( ! empty( $ctx['guard'] ) && ! empty( $spec['guard'] ) ) { 5616 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb, $on, $ids ); 5617 $type_expr = self::sql_product_type_expr(); 5618 $guard = ( 'external' === $spec['guard'] ) 5619 ? "{$type_expr} = 'external'" 5620 : "{$type_expr} NOT IN ( " . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 5621 $key = "( CASE WHEN {$guard} THEN {$key} ELSE NULL END )"; 5622 $blank = "( NOT ( {$guard} ) OR {$blank} )"; 5623 } 5624 5625 return [ 5626 'key' => $key, 5627 'blank' => $blank, 5628 'joins' => $joins, 5629 ]; 5630 } 5631 5632 /** 5633 * The key / blank pair for a raw SQL value under one of the spec `type`s. 5634 * 5635 * @return array{0: string, 1: string} [ key, blank ] 5636 */ 5637 private static function sort_type_expressions( string $type, string $raw ): array { 5638 switch ( $type ) { 5639 case 'numeric_text': 5640 return [ "CAST( NULLIF( TRIM( {$raw} ), '' ) AS DECIMAL(24,8) )", "NULLIF( TRIM( {$raw} ), '' ) IS NULL" ]; 5641 case 'numeric_native': 5642 return [ $raw, "{$raw} IS NULL" ]; 5643 default: 5644 return [ $raw, "COALESCE( {$raw}, '' ) = ''" ]; 5645 } 5646 } 5647 5648 /** 5649 * The key / blank SQL for a VARIATION row (alias `c`) on one spec — what the 5650 * grid DISPLAYS for that variation — plus the JOINs needed: the PER-ROW form, 5651 * consumed by order_children_by_sql() (#247). The ids restrict the grouped 5652 * derived tables to the chunk's children / real parents; null means 5653 * unrestricted, as everywhere (sql_id_restriction()) — never "aggregate": the 5654 * parent aggregate has its own entry point, child_aggregate_expressions(). 5655 * Precondition: spec_orders_children_in_sql(). 5656 * 5657 * Parent-side values are read through sql_join_first_meta() on 5658 * `c.post_parent` (lowest meta_id wins): WP does not enforce uniqueness on 5659 * (post_id, meta_key), and a plain postmeta join on a duplicated parent row 5660 * would multiply the child rows and hand one child two ranks. 5661 * 5662 * @param array<string, mixed> $spec 5663 * @param int[]|null $child_ids 5664 * @param int[]|null $parent_ids 5665 * @return array{key: string, blank: string, joins: array<string, string>} 5666 */ 5667 private static function child_sort_expressions( array $spec, bool $asc, ?array $child_ids, ?array $parent_ids ): array { 5668 global $wpdb; 5669 5670 if ( ! is_array( $spec['child'] ) ) { 5671 return self::resolve_sort_expressions( $spec, $asc, [ 'row' => 'c', 'ids' => $child_ids, 'guard' => false ] ); 5672 } 5673 5674 $child = $spec['child']; 5675 $meta = (string) $child['meta']; 5676 $joins = [ 5677 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID', $child_ids ), 5678 'wcabe_sort_par' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_par', 'c.post_parent', $parent_ids ), 5679 ]; 5680 $own = 'wcabe_sort_chm.meta_value'; 5681 $parent = 'wcabe_sort_par.meta_value'; 5682 5683 if ( 'parent_meta' === $child['inherit'] ) { 5684 // WC_Product_Variation::get_weight() & co. (view context): the parent's 5685 // value whenever the own one, read through wc_format_decimal(), is 5686 // empty() — see sql_formats_empty(). 5687 $eff = '( CASE WHEN ' . self::sql_formats_empty( $own ) . " THEN {$parent} ELSE {$own} END )"; 5688 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $eff ); 5689 return [ 5690 'key' => $key, 5691 'blank' => $blank, 5692 'joins' => $joins, 5693 ]; 5694 } 5695 5696 // managed_stock: the parent's value while the child does not manage stock 5697 // and the parent does (get_stock_quantity() / get_backorders(), view 5698 // context — "manages" is WC's wc_string_to_bool() set, see 5699 // sql_manages_stock()). 5700 $joins['wcabe_sort_chms'] = self::sql_join_first_meta( $wpdb, '_manage_stock', 'wcabe_sort_chms', 'c.ID', $child_ids ); 5701 $joins['wcabe_sort_parms'] = self::sql_join_first_meta( $wpdb, '_manage_stock', 'wcabe_sort_parms', 'c.post_parent', $parent_ids ); 5702 $inherits = '( NOT ' . self::sql_manages_stock( 'wcabe_sort_chms.meta_value', true ) 5703 . ' AND ' . self::sql_manages_stock( 'wcabe_sort_parms.meta_value', true ) . ' )'; 5704 5705 if ( 'stock_qty' === $child['type'] ) { 5706 // Mirrors the stock-quantity filter rules: an own _stock row that is 5707 // missing or byte-empty is NULL (no quantity), an inherited parent row 5708 // that is missing or empty reads as 0 (WC's parent-data reader). The 5709 // cast makes '10' rank after '2'; a whitespace-only value casts to 0 5710 // exactly as wc_stock_amount() does. 5711 $own_qty = "CASE WHEN {$own} IS NULL OR OCTET_LENGTH( {$own} ) = 0 THEN NULL ELSE {$own} END"; 5712 $parent_qty = "CASE WHEN {$parent} IS NULL OR OCTET_LENGTH( {$parent} ) = 0 THEN '0' ELSE {$parent} END"; 5713 $eff = "( CASE WHEN {$inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 5714 return [ 5715 'key' => "CAST( TRIM( {$eff} ) AS DECIMAL(24,8) )", 5716 'blank' => "{$eff} IS NULL", 5717 'joins' => $joins, 5718 ]; 5719 } 5720 5721 // string (backorders): a missing OWN row ranks as the spec default ('no', 5722 // the getter's default); a missing PARENT row inherits '' exactly as WC's 5723 // parent-data reader supplies it; an explicitly stored '' survives either 5724 // branch (set_backorders() keeps it) and is blank — derived from the 5725 // effective value, never a constant. 5726 $default = esc_sql( (string) ( $spec['default'] ?? '' ) ); 5727 $eff = "( CASE WHEN {$inherits} THEN COALESCE( {$parent}, '' ) ELSE COALESCE( {$own}, '{$default}' ) END )"; 5728 return [ 5729 'key' => $eff, 5730 'blank' => "( {$eff} = '' )", 5731 'joins' => $joins, 5732 ]; 5733 } 5734 5735 /** 5736 * The child expressions for the PARENT AGGREGATE (unrestricted, catalogue-wide 5737 * derived table) — the one entry point build_sort_clauses() calls. Same 5738 * displayed-value semantics as the per-row form in child_sort_expressions(). 5739 * 5740 * - child 'same': the spec's own expressions on the variation row, exactly 5741 * as the per-row form builds them, unrestricted. 5742 * 5743 * Inherit specs take a shape that needs NO parent-side join: an INHERITING 5744 * child displays the parent's own value, and the parent's own value already 5745 * participates in the family extreme (build_sort_clauses() folds `own_nb` 5746 * in), so such a child contributes nothing new and is emitted as NULL. 5747 * Measured at 20k products / 100k 5748 * variations: the per-row form's four catalogue-wide groupings cost 1.6–2.0s 5749 * per page against ~0.3s here. 5750 * 5751 * - parent_meta: own value unless it formats empty() (sql_formats_empty(): 5752 * inherits → NULL here) — the same predicate as the per-row 5753 * form, with the same documented limits. 5754 * - managed_stock: "child manages" is read from the child's lookup row — 5755 * WC_Product_Data_Store_CPT::get_data_for_lookup_table() 5756 * writes stock_quantity only when `_manage_stock` is 5757 * byte-exactly 'yes', NULL otherwise — so no `_manage_stock` 5758 * grouping is needed. A non-managing child either inherits 5759 * (the parent participates) or shows its own value, which 5760 * WC_Product::validate_props() has cleared to '' / 'no' on 5761 * any WC save (a non-managing PARENT's own backorders is that 5762 * same 'no'). The VALUE is still the child's own meta row 5763 * under the per-row empty rules — never the lookup number, 5764 * which turns an empty `_stock` into 0. Exact for WC-saved 5765 * data; a third-party `_manage_stock = '1'` child manages 5766 * per-row but not here (accepted, documented in CLAUDE.md). 5767 * 5768 * @return array{key: string, blank: string, joins: array<string, string>} 5769 */ 5770 private static function child_aggregate_expressions( array $spec, bool $asc ): array { 5771 global $wpdb; 5772 5773 if ( ! is_array( $spec['child'] ) ) { 5774 return self::resolve_sort_expressions( $spec, $asc, [ 'row' => 'c', 'ids' => null, 'guard' => false ] ); 5775 } 5776 5777 $child = $spec['child']; 5778 $meta = (string) $child['meta']; 5779 5780 if ( 'parent_meta' === $child['inherit'] ) { 5781 $own = 'wcabe_sort_chm.meta_value'; 5782 $eff = '( CASE WHEN ' . self::sql_formats_empty( $own ) . " THEN NULL ELSE {$own} END )"; 5783 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $eff ); 5784 return [ 5785 'key' => $key, 5786 'blank' => $blank, 5787 'joins' => [ 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID' ) ], 5788 ]; 5789 } 5790 5791 $lookup = self::SORT_LOOKUP_ALIAS; 5792 $joins = [ 5793 $lookup => " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$lookup} ON c.ID = {$lookup}.product_id ", 5794 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID' ), 5795 ]; 5796 // The lookup is the manage FLAG only. Its stock_quantity is 5797 // wc_stock_amount( _stock ) — an empty _stock becomes 0 there — so the 5798 // VALUE is read from the child's own meta row with the per-row form's 5799 // empty rule, or the two forms would disagree on a managing child with no 5800 // quantity (blank in the grid and in the ordering, 0 in the lookup). 5801 $manages = "{$lookup}.stock_quantity IS NOT NULL"; 5802 $own = 'wcabe_sort_chm.meta_value'; 5803 5804 if ( 'stock_qty' === $child['type'] ) { 5805 $eff = "( CASE WHEN {$manages} AND {$own} IS NOT NULL AND OCTET_LENGTH( {$own} ) > 0 THEN {$own} END )"; 5806 return [ 5807 'key' => "CAST( TRIM( {$eff} ) AS DECIMAL(24,8) )", 5808 'blank' => "{$eff} IS NULL", 5809 'joins' => $joins, 5810 ]; 5811 } 5812 5813 $default = esc_sql( (string) ( $spec['default'] ?? '' ) ); 5814 $eff = "( CASE WHEN {$manages} THEN COALESCE( {$own}, '{$default}' ) END )"; 5815 return [ 5816 'key' => $eff, 5817 'blank' => "( {$eff} IS NULL OR {$eff} = '' )", 5818 'joins' => $joins, 5819 ]; 4538 5820 } 4539 5821 … … 4680 5962 */ 4681 5963 private static function sql_decimal_range_or_keep( \wpdb $wpdb, string $expr, $min, $max ): string { 4682 return self::sql_range_or_keep( $wpdb, $expr, "'^-?[0-9]{1,9}([.][0-9]+)?$'", 'DECIMAL(12,2)', '%f', $min, $max ); 5964 return self::sql_range_or_keep( $wpdb, $expr, self::SQL_PLAIN_DECIMAL_REGEX, 'DECIMAL(12,2)', '%f', $min, $max ); 5965 } 5966 5967 /** 5968 * The variation stock quantity range test over $expr (the reported quantity, 5969 * already known non-NULL), for either child-predicate mode. Returns '' when no 5970 * range test is safe, i.e. the caller keeps every child with a quantity. 5971 * 5972 * Neither mode may reject a child that variation_matches_filters() accepts. 5973 * PHP compares wc_stock_amount( stored ), so the form depends on which 5974 * `woocommerce_stock_amount` normaliser this request runs 5975 * (stock_amount_normaliser()); non-plain values (huge, exponent form, 5976 * whitespace, non-numeric) are always kept: 5977 * 5978 * normaliser surface (decides parents) candidate (#218; bounds pre-widened ±1) 5979 * intval integer part vs the EXACT bounds DECIMAL(12,2) range-or-keep 5980 * floatval DECIMAL(12,2) range-or-keep ±0.01 DECIMAL(12,2) range-or-keep 5981 * unknown no range test no range test 5982 * 5983 * intval: SUBSTRING_INDEX( x, '.', 1 ) is intval()'s truncation toward zero 5984 * on a plain decimal of at most six fraction digits ('9.9' → 9, '-0.5' → '-0' 5985 * → 0; see SQL_INTVAL_EXACT_DECIMAL_REGEX for why longer fractions are kept), 5986 * so WC-saved data surfaces exactly — no empty parent row, no hidden variation. floatval: the cast 5987 * rounds to cents, at most 0.005 away from PHP's 6-place value, which the 5988 * ±0.01 absorbs. unknown: a custom normaliser can move a value arbitrarily 5989 * far (1000 → 10), so no finite widening is a superset; such sites may get 5990 * empty parent rows but never lose a matching variation. 5991 * 5992 * @param \wpdb $wpdb 5993 * @param string $expr SQL expression yielding the stored string. 5994 * @param int|float|null $min 5995 * @param int|float|null $max 5996 * @param bool $candidate Candidate mode (bounds already widened). 5997 * @return string 5998 */ 5999 private static function sql_stock_qty_bound( \wpdb $wpdb, string $expr, $min, $max, bool $candidate ): string { 6000 $normaliser = self::stock_amount_normaliser(); 6001 6002 if ( 'unknown' === $normaliser ) { 6003 return ''; 6004 } 6005 6006 if ( $candidate ) { 6007 return self::sql_decimal_range_or_keep( $wpdb, $expr, $min, $max ); 6008 } 6009 6010 if ( 'floatval' === $normaliser ) { 6011 return self::sql_decimal_range_or_keep( 6012 $wpdb, 6013 $expr, 6014 null === $min ? null : $min - 0.01, 6015 null === $max ? null : $max + 0.01 6016 ); 6017 } 6018 6019 $numeric = '( ' . $expr . ' REGEXP ' . self::SQL_INTVAL_EXACT_DECIMAL_REGEX . ' )'; 6020 $bound = self::sql_bound( $wpdb, "CAST( SUBSTRING_INDEX( {$expr}, '.', 1 ) AS SIGNED )", $min, $max, '%f' ); 6021 return "( NOT {$numeric} OR {$bound} )"; 6022 } 6023 6024 /** 6025 * Which normaliser wc_stock_amount() applies on this request: 'intval' (WC's 6026 * default, the only callback), 'floatval' (the only callback, or none — then 6027 * WC's NumberUtil::normalize() floatval()s the raw numeric string and rounds 6028 * to 6 places), or 'unknown' for anything else. Read per call: a filter added 6029 * mid-request must not leave a stale classification behind. 6030 * 6031 * @return string 'intval' | 'floatval' | 'unknown' 6032 */ 6033 private static function stock_amount_normaliser(): string { 6034 global $wp_filter; 6035 6036 $hook = $wp_filter['woocommerce_stock_amount'] ?? null; 6037 if ( ! $hook instanceof \WP_Hook || ! $hook->has_filters() ) { 6038 return 'floatval'; 6039 } 6040 6041 $functions = []; 6042 foreach ( $hook->callbacks as $by_priority ) { 6043 foreach ( $by_priority as $callback ) { 6044 $functions[] = $callback['function']; 6045 } 6046 } 6047 6048 if ( 1 === count( $functions ) && is_string( $functions[0] ) ) { 6049 $name = strtolower( ltrim( $functions[0], '\\' ) ); 6050 if ( 'intval' === $name || 'floatval' === $name ) { 6051 return $name; 6052 } 6053 } 6054 6055 return 'unknown'; 4683 6056 } 4684 6057 … … 4766 6139 * and the caller has already widened the bounds. 4767 6140 * 4768 * The inheritance CASEs read the PARENT's _manage_stock / _backorders from 4769 * `$spec['parent_ms_col']` / `$spec['parent_bk_col']` when the caller supplies 4770 * them — the surface caller passes its outer wcabe_ms_meta / wcabe_bk_meta 4771 * aliases, which the correlated EXISTS can reference directly, so the main 6141 * The inheritance CASEs read the PARENT's _manage_stock / _backorders / _stock 6142 * from `$spec['parent_ms_col']` / `$spec['parent_bk_col']` / 6143 * `$spec['parent_stock_col']` when the caller supplies them — the surface caller 6144 * passes its outer wcabe_ms_meta / wcabe_bk_meta / wcabe_stock_meta aliases, 6145 * which the correlated EXISTS can reference directly, so the main 4772 6146 * query does not re-join the same parent rows once per child row scanned. 4773 6147 * When absent (the standalone candidate query has no outer row) the fragment … … 4783 6157 * null (never matches). 4784 6158 * 6159 * Stock quantity and stock status are the two axes whose surface form is NOT 6160 * the collation-bound one: they joined the surface EXISTS in #259 and #273 (with 6161 * no earlier parent selection to preserve), so both are byte-exact in both 6162 * modes. Stock quantity's range test is never tighter than PHP in either — see 6163 * sql_stock_qty_bound() for the per-normaliser forms; stock status mirrors 6164 * PHP's read of the stored row, WooCommerce's instock normalisation included 6165 * (a view-context get_stock_status filter or a duplicate meta row is outside 6166 * it, as on every other axis). 6167 * 4785 6168 * @param array $spec Predicate inputs — see resolve_variation_child_spec(). 4786 * Optional `parent_ms_col` / `parent_bk_col`: SQL column 4787 * expressions for the parent's _manage_stock / _backorders 4788 * already joined by the caller (surface mode only). 6169 * Optional `parent_ms_col` / `parent_bk_col` / 6170 * `parent_stock_col`: SQL column expressions for the 6171 * parent's _manage_stock / _backorders / _stock already 6172 * joined by the caller (surface mode only). 4789 6173 * @param string $mode 'surface' or 'candidate'. 4790 6174 * @return array{joins: string, parts: string[]} … … 4808 6192 // surfaces on a child the narrowing then drops. 4809 6193 $manages = static function ( string $col ) use ( $candidate ): string { 4810 return $candidate 4811 ? "( CAST( LOWER( COALESCE( {$col}, 'no' ) ) AS BINARY ) IN ( 'yes', '1', 'true' ) )" 4812 : "( COALESCE( {$col}, 'no' ) IN ( 'yes', '1', 'true' ) )"; 6194 return self::sql_manages_stock( $col, $candidate ); 4813 6195 }; 4814 6196 4815 6197 if ( ! empty( $spec['cogs'] ) ) { 4816 6198 $col = 'wcabe_child_cogs.meta_value'; 4817 $joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogsON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) ";6199 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_cogs' ) . " ON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) "; 4818 6200 $bound = $candidate 4819 6201 ? self::sql_decimal_range_or_keep( $wpdb, $col, $spec['cogs']['min'], $spec['cogs']['max'] ) … … 4830 6212 // non-attachment means no row — the same "no match" PHP reaches through 4831 6213 // Product_Helper::get_image_upload_date(). 4832 $joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_metaON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) ";6214 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_img_meta' ) . " ON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) "; 4833 6215 $joins .= " INNER JOIN {$wpdb->posts} AS wcabe_child_img ON ( wcabe_child_img.ID = CAST( wcabe_child_img_meta.meta_value AS UNSIGNED ) AND wcabe_child_img.post_type = 'attachment' ) "; 4834 6216 $parts[] = self::sql_bound( $wpdb, 'wcabe_child_img.post_date', $spec['image']['min'], $spec['image']['max'], '%s' ); … … 4837 6219 foreach ( (array) ( $spec['sale'] ?? [] ) as $range ) { 4838 6220 $col = "{$range['child_alias']}.meta_value"; 4839 $joins .= " INNER JOIN {$wpdb->postmeta} AS {$range['child_alias']}ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) ";6221 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( $range['child_alias'] ) . " ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) "; 4840 6222 $bound = $candidate 4841 6223 ? self::sql_timestamp_range_or_keep( $wpdb, $col, $range['min'], $range['max'] ) … … 4850 6232 // _backorders / _stock row must still exist for the inheritance branch, 4851 6233 // where the parent's value is used instead. 4852 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_msON ( wcabe_child_ms.post_id = c.ID AND wcabe_child_ms.meta_key = '_manage_stock' ) ";6234 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_ms' ) . " ON ( wcabe_child_ms.post_id = c.ID AND wcabe_child_ms.meta_key = '_manage_stock' ) "; 4853 6235 4854 6236 $parent_ms_col = $spec['parent_ms_col'] ?? null; 4855 6237 if ( null === $parent_ms_col ) { 4856 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pmsON ( wcabe_child_pms.post_id = c.post_parent AND wcabe_child_pms.meta_key = '_manage_stock' ) ";6238 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pms' ) . " ON ( wcabe_child_pms.post_id = c.post_parent AND wcabe_child_pms.meta_key = '_manage_stock' ) "; 4857 6239 $parent_ms_col = 'wcabe_child_pms.meta_value'; 4858 6240 } … … 4869 6251 4870 6252 if ( ! empty( $spec['backorders'] ) ) { 4871 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_bkON ( wcabe_child_bk.post_id = c.ID AND wcabe_child_bk.meta_key = '_backorders' ) ";6253 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_bk' ) . " ON ( wcabe_child_bk.post_id = c.ID AND wcabe_child_bk.meta_key = '_backorders' ) "; 4872 6254 $parent_bk_col = $spec['parent_bk_col'] ?? null; 4873 6255 if ( null === $parent_bk_col ) { 4874 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pbkON ( wcabe_child_pbk.post_id = c.post_parent AND wcabe_child_pbk.meta_key = '_backorders' ) ";6256 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pbk' ) . " ON ( wcabe_child_pbk.post_id = c.post_parent AND wcabe_child_pbk.meta_key = '_backorders' ) "; 4875 6257 $parent_bk_col = 'wcabe_child_pbk.meta_value'; 4876 6258 } … … 4879 6261 4880 6262 if ( ! empty( $spec['stock_qty'] ) ) { 4881 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_stock ON ( wcabe_child_stock.post_id = c.ID AND wcabe_child_stock.meta_key = '_stock' ) "; 4882 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pstock ON ( wcabe_child_pstock.post_id = c.post_parent AND wcabe_child_pstock.meta_key = '_stock' ) "; 4883 // Byte-exact empty checks: under PAD SPACE collations NULLIF( col, '' ) 4884 // would turn an own _stock of ' ' into NULL and drop the child, while 4885 // PHP's '' !== ' ' keeps it and wc_stock_amount( ' ' ) yields 0. 4886 $own_qty = 'CASE WHEN wcabe_child_stock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_stock.meta_value ) = 0 THEN NULL ELSE wcabe_child_stock.meta_value END'; 4887 $parent_qty = "CASE WHEN wcabe_child_pstock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_pstock.meta_value ) = 0 THEN '0' ELSE wcabe_child_pstock.meta_value END"; 4888 $qty = "( CASE WHEN {$inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 4889 $parts[] = "( {$qty} IS NOT NULL AND " . self::sql_decimal_range_or_keep( $wpdb, $qty, $spec['stock_qty']['min'], $spec['stock_qty']['max'] ) . ' )'; 6263 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_stock' ) . " ON ( wcabe_child_stock.post_id = c.ID AND wcabe_child_stock.meta_key = '_stock' ) "; 6264 6265 $parent_stock_col = $spec['parent_stock_col'] ?? null; 6266 if ( null === $parent_stock_col ) { 6267 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pstock' ) . " ON ( wcabe_child_pstock.post_id = c.post_parent AND wcabe_child_pstock.meta_key = '_stock' ) "; 6268 $parent_stock_col = 'wcabe_child_pstock.meta_value'; 6269 } 6270 6271 // Byte-exact in BOTH modes, inheritance included: this predicate 6272 // decides parents only since #259 and has no earlier collation-bound 6273 // selection to preserve, and it must never be tighter than PHP. 6274 // Under PAD SPACE collations NULLIF( col, '' ) would turn an own 6275 // _stock of ' ' into NULL and drop the child, while PHP's '' !== ' ' 6276 // keeps it and wc_stock_amount( ' ' ) yields 0. 6277 $qty_inherits = 'NOT ' . self::sql_manages_stock( 'wcabe_child_ms.meta_value', true ) . ' AND ' . self::sql_manages_stock( $parent_ms_col, true ); 6278 $own_qty = 'CASE WHEN wcabe_child_stock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_stock.meta_value ) = 0 THEN NULL ELSE wcabe_child_stock.meta_value END'; 6279 $parent_qty = "CASE WHEN {$parent_stock_col} IS NULL OR OCTET_LENGTH( {$parent_stock_col} ) = 0 THEN '0' ELSE {$parent_stock_col} END"; 6280 $qty = "( CASE WHEN {$qty_inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 6281 $bound = self::sql_stock_qty_bound( $wpdb, $qty, $spec['stock_qty']['min'], $spec['stock_qty']['max'], $candidate ); 6282 $parts[] = '' === $bound ? "( {$qty} IS NOT NULL )" : "( {$qty} IS NOT NULL AND {$bound} )"; 4890 6283 } 4891 6284 } … … 4896 6289 // when 'instock' is selected, every such row must be KEPT; otherwise it 4897 6290 // reports 'instock', which is not selected, and cannot match anyway. 6291 // Byte-exact in BOTH modes (#273): this predicate decides parents only 6292 // since then and has no earlier collation-bound selection to preserve. 6293 // A collation match would surface a parent through a child stored as 6294 // 'OUTOFSTOCK', which PHP reports as instock and drops — an empty row. 4898 6295 $col = 'wcabe_child_ss.meta_value'; 4899 $ joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_ss ON ( wcabe_child_ss.post_id = c.ID AND wcabe_child_ss.meta_key = '_stock_status' )";4900 $ in = self::sql_in( $wpdb, $exact( $col ), $spec['stock_status'] );4901 $ valid = array_values( array_map( 'strval', array_keys( (array) wc_get_product_stock_status_options() ) ));4902 $parts[] = in_array( 'instock', $spec['stock_status'], true ) && ! empty( $valid)4903 ? "( {$in} OR {$col} IS NULL OR NOT " . self::sql_in( $wpdb, $ exact( $col ), $valid) . ' )'6296 $bin = "CAST( {$col} AS BINARY )"; 6297 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_ss' ) . " ON ( wcabe_child_ss.post_id = c.ID AND wcabe_child_ss.meta_key = '_stock_status' ) "; 6298 $in = self::sql_in( $wpdb, $bin, $spec['stock_status'] ); 6299 $parts[] = self::stock_status_child_matches_unset( $spec['stock_status'] ) 6300 ? "( {$in} OR {$col} IS NULL OR NOT " . self::sql_in( $wpdb, $bin, self::valid_stock_statuses() ) . ' )' 4904 6301 : $in; 4905 6302 } … … 4909 6306 'parts' => $parts, 4910 6307 ]; 6308 } 6309 6310 /** 6311 * The stock statuses WooCommerce accepts, as set_stock_status() checks them. 6312 * 6313 * @return string[] 6314 */ 6315 private static function valid_stock_statuses(): array { 6316 return array_values( array_map( 'strval', array_keys( (array) wc_get_product_stock_status_options() ) ) ); 6317 } 6318 6319 /** 6320 * Whether the variation stock-status predicate for $values also matches a 6321 * variation whose stored _stock_status is missing, empty or invalid — the 6322 * rows set_stock_status() reads as 'instock'. True exactly when 'instock' is 6323 * selected and WooCommerce lists its valid statuses. 6324 * 6325 * Shared by build_variation_child_predicates() and the stock-status pre-read 6326 * (stock_status_surface_ids(), #276): the pre-read lists stored rows, so it 6327 * can stand in only for the predicate's plain IN form. 6328 * 6329 * @param string[] $values The selected statuses. 6330 */ 6331 private static function stock_status_child_matches_unset( array $values ): bool { 6332 return in_array( 'instock', $values, true ) && ! empty( self::valid_stock_statuses() ); 6333 } 6334 6335 /** 6336 * The stock-status matches of the combined surface clause (#276), read ONCE 6337 * from the _stock_status rows: `own`, the posts with a _stock_status row in 6338 * $values (collation-bound, as WC's meta_query the own branch replaces), and 6339 * `parents`, the parents of the renderable variations whose row is 6340 * byte-exactly in $values (the child predicate's plain IN form). 6341 * 6342 * Without it, the own branch probes the meta rows of every product and the 6343 * child EXISTS those of every variation of every parent it tests: for a rare 6344 * status, the whole catalogue's meta, one post_id probe at a time. 6345 * 6346 * Both lists are exact for their test, so rows, totals and ids cannot change: 6347 * - `own` replaces the own EXISTS outright: the same rows, the same 6348 * comparison (meta_value IN, under the column's collation), any post type, 6349 * and a duplicate row only repeats an id. 6350 * - `parents` is a necessary condition in FRONT of the child EXISTS, which 6351 * stays the gate, because every active axis must hold on the SAME 6352 * variation. It applies the EXISTS's variation type and status gates and 6353 * keys on the same real post_parent. 6354 * 6355 * Plan: STRAIGHT_JOIN reads postmeta first, by its meta_key condition, and the 6356 * post by primary key. It names no key, so unlike an index hint it cannot fail 6357 * on a missing one: without a meta_key index the read is one scan of postmeta 6358 * per query, never one per row. The post_id pin (CLAUDE.md rule 12) does not 6359 * apply: no row is looked up by post_id here, this is a scan of a whole meta 6360 * key. The collation-bound IN selects a superset of the byte-exact one and 6361 * lets a (meta_key, meta_value) index range-scan where a site has one. 6362 * 6363 * @param string[] $values The selected statuses ($args-derived, as both branches). 6364 * @param array $filters The active filters (for the kill switch). 6365 * @return array{own: int[], parents: int[]}|null Either list may be empty. Null: 6366 * keep both EXISTS — the selection also matches unset rows ('instock'), 6367 * the filter switched the read off, it failed, or more rows than the 6368 * cap matched. 6369 */ 6370 private function stock_status_surface_ids( array $values, array $filters ): ?array { 6371 global $wpdb; 6372 6373 if ( empty( $values ) || self::stock_status_child_matches_unset( $values ) ) { 6374 return null; 6375 } 6376 6377 /** 6378 * Filters whether the stock-status filter with variations reads the 6379 * matching products and variation parents in one pass over the 6380 * _stock_status rows, instead of probing each product and variation. 6381 * 6382 * Default true. The rows do not change either way; return false to keep 6383 * the per-row probes. 6384 * 6385 * @param bool $prefilter Whether to read the matches in one pass. 6386 * @param array $filters The active filters. 6387 */ 6388 if ( ! (bool) apply_filters( 'wcabe_g2_sql_prefilter_stock_status', true, $filters ) ) { 6389 return null; 6390 } 6391 6392 $cap = $this->stock_status_prefilter_cap(); 6393 $renders = "c.post_type = 'product_variation' AND c.post_status IN ( 'publish', 'private' ) AND " 6394 . self::sql_in( $wpdb, 'CAST( wcabe_ss_pre.meta_value AS BINARY )', $values ); 6395 6396 // The leading comment is a stable marker for the failure-injection test. 6397 $rows = self::run_chunk_query( 6398 "SELECT /* wcabe_g2_stock_status_ids */ wcabe_ss_pre.post_id, IF( {$renders}, c.post_parent, 0 ) AS surfaced_parent" 6399 . " FROM {$wpdb->postmeta} AS wcabe_ss_pre" 6400 . " STRAIGHT_JOIN {$wpdb->posts} AS c ON ( c.ID = wcabe_ss_pre.post_id )" 6401 . " WHERE wcabe_ss_pre.meta_key = '_stock_status' AND " . self::sql_in( $wpdb, 'wcabe_ss_pre.meta_value', $values ) 6402 . ' LIMIT ' . ( $cap + 1 ), 6403 'stock status pre-read failed; filtering with the per-row probes.' 6404 ); 6405 6406 if ( null === $rows || count( $rows ) > $cap ) { 6407 return null; 6408 } 6409 6410 $own = []; 6411 $parents = []; 6412 foreach ( $rows as $row ) { 6413 $own[ (int) $row->post_id ] = true; 6414 if ( (int) $row->surfaced_parent > 0 ) { 6415 $parents[ (int) $row->surfaced_parent ] = true; 6416 } 6417 } 6418 6419 return [ 6420 'own' => array_keys( $own ), 6421 'parents' => array_keys( $parents ), 6422 ]; 6423 } 6424 6425 /** 6426 * STOCK_STATUS_PREFILTER_CAP — a method so a test subclass can lower it. 6427 */ 6428 protected function stock_status_prefilter_cap(): int { 6429 return self::STOCK_STATUS_PREFILTER_CAP; 4911 6430 } 4912 6431 … … 5027 6546 5028 6547 return $spec; 6548 } 6549 6550 /** 6551 * Resolve how variations are ordered under their parent for a request's 6552 * sort (#247): null for the default `id desc` (no sort callback runs, WC's 6553 * child order is kept) or an unknown column; otherwise the spec, direction 6554 * and mode. Called once per request by get_products(), stream_products() and 6555 * get_variations() and passed down — it derives the spec table, which is 6556 * deliberately not memoised, so never call it per chunk. 6557 * 6558 * @return array{spec: array<string, mixed>, asc: bool, mode: string}|null 6559 * mode: 'sql' | 'id' (child 'none', or a spec keyed on the row ID 6560 * itself — ranked in PHP, no query) | 'display_name' 6561 */ 6562 private static function resolve_child_sort( string $orderby, string $order ): ?array { 6563 $asc = 'asc' === strtolower( $order ); 6564 if ( 'id' === $orderby && ! $asc ) { 6565 return null; 6566 } 6567 $spec = self::get_sort_specs()[ $orderby ] ?? null; 6568 if ( null === $spec || ! array_key_exists( 'child', $spec ) ) { 6569 return null; 6570 } 6571 if ( self::spec_ranks_children_by_id( $spec ) ) { 6572 // The ids are already in hand — a query would only ORDER BY c.ID. 6573 $mode = 'id'; 6574 } elseif ( self::spec_orders_children_in_sql( $spec ) ) { 6575 $mode = 'sql'; 6576 } else { 6577 $mode = 'none' === $spec['child'] ? 'id' : 'display_name'; 6578 } 6579 return [ 6580 'spec' => $spec, 6581 'asc' => $asc, 6582 'mode' => $mode, 6583 ]; 6584 } 6585 6586 /** 6587 * Order each parent's child-id list for the resolved sort: by SQL rank 6588 * ('sql'), by ID in the sort direction ('id'), or untouched 6589 * ('display_name' — ranked after formatting by order_formatted_children(), 6590 * which honours the same kill switch). 6591 * 6592 * @param array<int, int[]> $children_map 6593 * @param array $filters Passed to the kill-switch filter. 6594 * @return array<int, int[]> 6595 */ 6596 private function order_children_map( array $children_map, ?array $child_sort, array $filters ): array { 6597 if ( null === $child_sort || empty( $children_map ) ) { 6598 return $children_map; 6599 } 6600 if ( 'id' === $child_sort['mode'] ) { 6601 foreach ( $children_map as $parent_id => $children ) { 6602 $children = array_map( 'intval', (array) $children ); 6603 $child_sort['asc'] ? sort( $children, SORT_NUMERIC ) : rsort( $children, SORT_NUMERIC ); 6604 $children_map[ $parent_id ] = $children; 6605 } 6606 return $children_map; 6607 } 6608 if ( 'sql' !== $child_sort['mode'] ) { 6609 return $children_map; 6610 } 6611 /** 6612 * Filters whether variations are ordered under their parent in SQL for 6613 * the active sort (#247). Return false to keep WooCommerce's child order; 6614 * the parent ranking is unaffected. 6615 * 6616 * @param bool $order Whether to run the ordering query. 6617 * @param array $filters The active filters. 6618 */ 6619 if ( ! (bool) apply_filters( 'wcabe_g2_sql_order_variations', true, $filters ) ) { 6620 return $children_map; 6621 } 6622 return $this->order_children_by_sql( $children_map, $child_sort['spec'], $child_sort['asc'] ); 6623 } 6624 6625 /** 6626 * Order each parent's children by the sort column's DISPLAYED value in SQL 6627 * (#247): one chunked query per page over the chunk's child ids, ORDER BY 6628 * the same `(blank) ASC, key <dir>, ID <dir>` contract the parent sort uses, 6629 * with the expressions from child_sort_expressions() restricted to the 6630 * chunk's child / parent ids so no derived table groups the catalogue. 6631 * Chunks hold WHOLE parents — a rank is only meaningful within one parent. 6632 * A failed chunk keeps its parents' original order (a DB error must never 6633 * scramble a page); ids the query did not return keep their relative order 6634 * at the end. Only the ORDER changes: every list comes back with exactly the 6635 * entries it went in with — duplicates and non-positive values included — so 6636 * the rows a page emits never depend on the sort or on a query failing. 6637 * 6638 * @param array<int, int[]> $children_map parent id => child ids. 6639 * @return array<int, int[]> 6640 */ 6641 protected function order_children_by_sql( array $children_map, array $spec, bool $asc ): array { 6642 global $wpdb; 6643 6644 $lists = []; 6645 foreach ( $children_map as $parent_id => $children ) { 6646 $lists[ (int) $parent_id ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $children ), static fn( int $id ): bool => $id > 0 ) ) ); 6647 } 6648 $chunks = self::chunk_whole_parents( $lists, max( 1, $this->narrow_children_chunk_size() ) ); 6649 6650 $dir = $asc ? 'ASC' : 'DESC'; 6651 6652 foreach ( $chunks as $chunk ) { 6653 $child_ids = array_values( array_unique( array_merge( ...array_values( $chunk ) ) ) ); 6654 $parent_ids = null; 6655 if ( is_array( $spec['child'] ) ) { 6656 // Inherit specs read the parent side restricted to the REAL parents. 6657 // A failed lookup must not run the chunk on a guessed parent set 6658 // (unrestricted, or a stale result set): keep its stored order. 6659 $parent_ids = self::real_parent_ids( $wpdb, $child_ids ); 6660 if ( null === $parent_ids ) { 6661 continue; 6662 } 6663 } 6664 $expr = self::child_sort_expressions( $spec, $asc, $child_ids, $parent_ids ); 6665 6666 // NOT $wpdb->prepare(): the spec SQL is interpolated whole, and a `%d` / 6667 // `%s` inside a Pro choice label or ACF default ("5%discount") would read 6668 // as a placeholder, empty the statement and silently keep WC order. Every 6669 // id is int-cast (sql_id_restriction() and the list below), the joins and 6670 // expressions are this class's own fragments over those ids and esc_sql()'d 6671 // keys. The leading comment is a stable marker for the failure-injection test. 6672 $id_list = implode( ',', array_map( 'intval', $child_ids ) ); 6673 $sql = "SELECT /* wcabe_g2_order_variations */ c.ID FROM {$wpdb->posts} c" . implode( '', $expr['joins'] ) 6674 . " WHERE c.ID IN ({$id_list}) AND c.post_type = 'product_variation'" 6675 . " ORDER BY ( {$expr['blank']} ) ASC, {$expr['key']} {$dir}, c.ID {$dir}"; 6676 6677 $rows = self::run_chunk_query( $sql, 'variation ordering query failed; keeping the stored child order for this chunk.' ); 6678 if ( null === $rows ) { 6679 continue; 6680 } 6681 6682 $rank = []; 6683 foreach ( $rows as $row ) { 6684 $id = (int) $row->ID; 6685 if ( ! isset( $rank[ $id ] ) ) { 6686 $rank[ $id ] = count( $rank ); 6687 } 6688 } 6689 6690 // Reorder the ORIGINAL list, not the normalised copy the chunk holds: a 6691 // child sort only changes the order, so a duplicate or non-positive 6692 // entry a `woocommerce_get_children` filter supplied is kept exactly as 6693 // the default sort (and a failed chunk) would keep it. 6694 foreach ( array_keys( $chunk ) as $parent_id ) { 6695 $original = array_values( (array) $children_map[ $parent_id ] ); 6696 $positions = array_keys( $original ); 6697 usort( 6698 $positions, 6699 static function ( int $a, int $b ) use ( $rank, $original ): int { 6700 $ra = $rank[ (int) $original[ $a ] ] ?? PHP_INT_MAX; 6701 $rb = $rank[ (int) $original[ $b ] ] ?? PHP_INT_MAX; 6702 return ( $ra <=> $rb ) ?: ( $a <=> $b ); 6703 } 6704 ); 6705 $children_map[ $parent_id ] = array_map( static fn( int $i ) => $original[ $i ], $positions ); 6706 } 6707 } 6708 6709 return $children_map; 6710 } 6711 6712 /** 6713 * Split per-parent lists into query chunks of WHOLE parents (#247): a rank is 6714 * only meaningful within one parent, so a parent is never split — one with 6715 * more entries than $chunk_size gets a chunk of its own. Lists with fewer 6716 * than two entries have nothing to order and are left out. 6717 * 6718 * @param array<int|string, array<int, mixed>> $lists parent key => entries. 6719 * @return array<int, array<int|string, array<int, mixed>>> chunks, keys preserved. 6720 */ 6721 private static function chunk_whole_parents( array $lists, int $chunk_size ): array { 6722 $chunks = []; 6723 $current = []; 6724 $count = 0; 6725 foreach ( $lists as $key => $list ) { 6726 if ( count( $list ) < 2 ) { 6727 continue; 6728 } 6729 if ( $count > 0 && $count + count( $list ) > $chunk_size ) { 6730 $chunks[] = $current; 6731 $current = []; 6732 $count = 0; 6733 } 6734 $current[ $key ] = $list; 6735 $count += count( $list ); 6736 } 6737 if ( ! empty( $current ) ) { 6738 $chunks[] = $current; 6739 } 6740 return $chunks; 6741 } 6742 6743 /** 6744 * Rank formatted variation rows for a 'display_name' child sort (#247) — the 6745 * emitted `name` (variation_display_name()) is not a stored column, so the 6746 * formatted names go back to MySQL as a literal list and are ordered under 6747 * the EXACT collation the parent rows sort by: the derived table's first 6748 * branch is an empty SELECT of `posts.post_title`, and MySQL's collation 6749 * aggregation gives the column (IMPLICIT) precedence over the literals 6750 * (COERCIBLE), whatever the connection collation. Accent / case equivalents 6751 * and expansions (ß = ss under the UCA collations) therefore tie exactly 6752 * where they tie for parents, then fall to the ID tiebreak; blank (`= ''`, 6753 * PAD SPACE included) sorts last, as the parent-row rule does. 6754 * 6755 * One query per chunk of WHOLE parents (narrow_children_chunk_size() rows), 6756 * built with prepare() — names are arguments, so a `%` in a name is inert. 6757 * A failed chunk keeps its formatted (WooCommerce) order. On a utf8mb3 posts 6758 * table a name holding 4-byte characters can fail the literal-to-column 6759 * conversion — that chunk keeps its order too. Identity for every other mode 6760 * (the id lists were ordered before formatting). 6761 * 6762 * @param array<int|string, array<int, array<string, mixed>>> $groups One row list per parent, keyed as the caller likes. 6763 * @param array $filters Passed to the kill-switch filter. 6764 * @return array<int|string, array<int, array<string, mixed>>> Same keys, rows reordered. 6765 */ 6766 private function order_formatted_children( array $groups, ?array $child_sort, array $filters ): array { 6767 if ( null === $child_sort || 'display_name' !== $child_sort['mode'] || empty( $groups ) ) { 6768 return $groups; 6769 } 6770 /** This filter is documented in Product_Service::order_children_map(). */ 6771 if ( ! (bool) apply_filters( 'wcabe_g2_sql_order_variations', true, $filters ) ) { 6772 return $groups; 6773 } 6774 6775 global $wpdb; 6776 6777 $chunks = self::chunk_whole_parents( $groups, max( 1, $this->narrow_children_chunk_size() ) ); 6778 $dir = $child_sort['asc'] ? 'ASC' : 'DESC'; 6779 6780 foreach ( $chunks as $chunk ) { 6781 $branches = [ "SELECT ID AS id, post_title AS n FROM {$wpdb->posts} WHERE 1 = 0" ]; 6782 $args = []; 6783 foreach ( $chunk as $rows ) { 6784 foreach ( $rows as $row ) { 6785 $branches[] = 'SELECT %d, %s'; 6786 $args[] = (int) ( $row['id'] ?? 0 ); 6787 $args[] = (string) ( $row['name'] ?? '' ); 6788 } 6789 } 6790 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- the branches are `SELECT %d, %s` tokens expanded by prepare() via spread, plus the fixed empty post_title branch. The leading comment is a stable marker for the failure-injection test. 6791 $sql = $wpdb->prepare( 6792 'SELECT /* wcabe_g2_order_variation_names */ t.id FROM ( ' . implode( ' UNION ALL ', $branches ) . ' ) t' 6793 . " ORDER BY ( t.n = '' ) ASC, t.n {$dir}, t.id {$dir}", 6794 ...$args 6795 ); 6796 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 6797 6798 $ordered = self::run_chunk_query( $sql, 'variation name ordering query failed; keeping the stored child order for this chunk.' ); 6799 if ( null === $ordered ) { 6800 continue; 6801 } 6802 6803 $rank = []; 6804 foreach ( $ordered as $row ) { 6805 $id = (int) $row->id; 6806 if ( ! isset( $rank[ $id ] ) ) { 6807 $rank[ $id ] = count( $rank ); 6808 } 6809 } 6810 6811 foreach ( $chunk as $key => $rows ) { 6812 $positions = array_keys( $rows ); 6813 usort( 6814 $positions, 6815 static function ( int $a, int $b ) use ( $rank, $rows ): int { 6816 $ra = $rank[ (int) ( $rows[ $a ]['id'] ?? 0 ) ] ?? PHP_INT_MAX; 6817 $rb = $rank[ (int) ( $rows[ $b ]['id'] ?? 0 ) ] ?? PHP_INT_MAX; 6818 return ( $ra <=> $rb ) ?: ( $a <=> $b ); 6819 } 6820 ); 6821 $groups[ $key ] = array_map( static fn( int $i ): array => $rows[ $i ], $positions ); 6822 } 6823 } 6824 6825 return $groups; 5029 6826 } 5030 6827 … … 5123 6920 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 5124 6921 5125 // Suppress only the printing of a failed query. query() returns false on 5126 // EVERY failure path (SQL error, emptied by the `query` filter, connection 5127 // not ready); only a non-false return guarantees last_result is fresh. 5128 $was_suppressed = $wpdb->suppress_errors( true ); 5129 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is the return value of $wpdb->prepare() above; a per-request pre-filter over a chunked IN list, nothing to cache. 5130 $result = $wpdb->query( $sql ); 5131 $wpdb->suppress_errors( $was_suppressed ); 5132 5133 if ( false === $result ) { 5134 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'wc_get_logger' ) ) { 5135 wc_get_logger()->warning( 'WCABE G2: variation pre-filter query failed; hydrating the full child lists for this chunk. ' . $wpdb->last_error, [ 'source' => 'wcabe-g2' ] ); 5136 } 6922 $rows = self::run_chunk_query( $sql, 'variation pre-filter query failed; hydrating the full child lists for this chunk.' ); 6923 if ( null === $rows ) { 5137 6924 foreach ( $chunk as $child_id ) { 5138 6925 foreach ( $child_parents[ $child_id ] as $parent_id ) { … … 5143 6930 } 5144 6931 5145 foreach ( (array) $wpdb->last_resultas $row ) {6932 foreach ( $rows as $row ) { 5146 6933 $survivor_set[ (int) $row->ID ] = true; 5147 6934 } … … 5182 6969 // TODO: Phase 5 — consider capping variations per product when include_variations is on, 5183 6970 // for extreme edge cases (5000+ variations on a single product). 5184 private function interleave_variations( array $formatted_products, array $filters = [], array $children_map = [] ): array {6971 private function interleave_variations( array $formatted_products, array $filters = [], array $children_map = [], ?array $child_sort = null ): array { 5185 6972 $result = []; 5186 6973 $has_meta_filter = $this->has_variation_meta_filters( $filters ); … … 5189 6976 $attribute_slugs = $this->resolve_attribute_filter_slugs( $filters['attribute_filter'] ?? [] ); 5190 6977 5191 foreach ( $formatted_products as $product ) { 5192 $result[] = $product; 5193 6978 // Two passes: format every parent's variation rows first, so the 6979 // formatted-name sort can rank the whole page in chunked queries (#247), 6980 // then emit parents and their rows in page order. 6981 $groups = []; 6982 foreach ( $formatted_products as $index => $product ) { 5194 6983 // variation_count is only non-zero for variable-capable products 5195 6984 // (format_product gates it on instanceof WC_Product_Variable), so it … … 5241 7030 } 5242 7031 7032 $rows = []; 5243 7033 foreach ( $children as $child_id ) { 5244 7034 $variation = wc_get_product( $child_id ); … … 5247 7037 continue; 5248 7038 } 5249 $r esult[] = Product_Helper::format_variation( $variation );7039 $rows[] = Product_Helper::format_variation( $variation ); 5250 7040 } 5251 7041 } 7042 $groups[ $index ] = $rows; 7043 } 7044 } 7045 7046 // $children arrived already ordered for SQL / id child sorts; only the 7047 // formatted-name sort needs the rows themselves (#247). 7048 $groups = $this->order_formatted_children( $groups, $child_sort, $filters ); 7049 7050 foreach ( $formatted_products as $index => $product ) { 7051 $result[] = $product; 7052 foreach ( $groups[ $index ] ?? [] as $row ) { 7053 $result[] = $row; 5252 7054 } 5253 7055 } … … 5256 7058 } 5257 7059 5258 // TODO: If post-launch profiling shows repeated queries across 5259 // filter-change cycles are expensive on large stores, cache the 5260 // result in a transient keyed on md5(serialize($filters)) with 5261 // ~60s TTL. React Query already handles same-filter pagination 5262 // client-side, so this would only help the filter-churn case. 5263 /** 5264 * Count total rows (parents + variations) that would be rendered if 5265 * include_variations=true for the current filter set, across ALL matching 5266 * pages. Only the variable-parents' children contribute extra rows; simple, 5267 * grouped, and external products already count as one row each in $total. 5268 * 5269 * Returns an approximate flag because the raw COUNT(*) does not replicate 5270 * variation_matches_filters() — variation-level filters (price, stock_qty, 7060 /** 7061 * Count the variation rows the grid renders under the given variable parents: 7062 * their publish/private variations, all of them. Simple, grouped and external 7063 * products add no rows beyond their own, which the page total already counts. 7064 * 7065 * Every variation counts, matching or not: the raw COUNT(*) does not replicate 7066 * variation_matches_filters(), so variation-level filters (price, stock_qty, 5271 7067 * menu_order, manage_stock, backorders, search_description, search_sku, 5272 * attribute_filter) cause an over-count at render time. Accepted as a safe 5273 * upper bound. 5274 * 5275 * @param int $total_parents Total parents matching filters (from main query). 5276 * @param array $filters Same filters passed to get_products_wc_crud(). 5277 * @return array{total: int, is_approximate: bool} 5278 */ 5279 private function get_total_with_variations( int $total_parents, array $filters ): array { 5280 $variable_parent_ids = $this->get_matching_variable_parent_ids( $filters ); 5281 5282 if ( empty( $variable_parent_ids ) ) { 5283 return [ 5284 'total' => $total_parents, 5285 'is_approximate' => false, 5286 ]; 5287 } 5288 7068 * attribute_filter) over-count. Accepted as a safe upper bound; the caller 7069 * flags it through has_variation_meta_filters(). 7070 * 7071 * The client caches the result per filter set (#274), so page and sort changes 7072 * never re-run it. A server-side cache would only help repeated filter changes. 7073 * 7074 * @param int[] $variable_parent_ids From get_matching_variable_parent_ids(). 7075 * @return int 7076 */ 7077 private function count_renderable_variations( array $variable_parent_ids ): int { 5289 7078 global $wpdb; 5290 7079 $variation_count = 0; … … 5301 7090 // renders those. Counting draft/pending here would over-report rows 5302 7091 // that never display. Parent status filtering is already applied via 5303 // the parent-ID query above; this clause is the independent7092 // get_matching_variable_parent_ids(); this clause is the independent 5304 7093 // VARIATION-status axis in WC's data model. 5305 7094 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%d` placeholders, one per element of $chunk, all expanded by prepare() via spread. … … 5316 7105 } 5317 7106 5318 return [ 5319 'total' => $total_parents + $variation_count, 5320 'is_approximate' => $this->has_variation_meta_filters( $filters ), 5321 ]; 7107 return $variation_count; 5322 7108 } 5323 7109 … … 5757 7543 continue; 5758 7544 } 5759 $resolved[ sanitize_title( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );7545 $resolved[ Attribute_Service::attribute_key( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) ); 5760 7546 } 5761 7547 return $resolved; -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/View_Service.php
r3663491 r3736631 140 140 } 141 141 142 update_user_meta( $user_id, 'wcabe_g2_views', $views );142 $this->persist( $user_id, $views ); 143 143 144 144 return $view; … … 203 203 // create_view sanitize on the touched view is sufficient since other 204 204 // views were already sanitized at their own create/update time. 205 update_user_meta( $user_id, 'wcabe_g2_views', $views );205 $this->persist( $user_id, $views ); 206 206 207 207 return $views[ $index ]; … … 241 241 // create_view sanitize on the touched view is sufficient since other 242 242 // views were already sanitized at their own create/update time. 243 update_user_meta( $user_id, 'wcabe_g2_views', $views );243 $this->persist( $user_id, $views ); 244 244 245 245 return true; … … 374 374 } 375 375 376 // Category mode (#147). Same canonical form as the client's 377 // effectiveCategoryOperator(): an exclude mode without categories means nothing, 378 // and would make a freshly loaded view (or a replayed filter draft, which never 379 // passes through the store's apply()) report a phantom "Modified". 380 $category = array_map( 'absint', (array) ( $filters['category'] ?? [] ) ); 381 $category_operator = $filters['category_operator'] ?? 'in'; 382 if ( ! in_array( $category_operator, Product_Service::CATEGORY_OPERATORS, true ) || empty( $category ) ) { 383 $category_operator = 'in'; 384 } 385 376 386 // Product URL filter. Canonicalized here as well as in the client store, 377 387 // because a view can also be created/imported straight through the REST … … 396 406 'search_sku' => $search_sku, 397 407 'search_sku_mode' => $search_sku_mode, 398 'category' => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ), 408 'category' => $category, 409 'category_operator' => $category_operator, 399 410 'tag' => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ), 400 411 'type' => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ), … … 520 531 521 532 /** 533 * The single writer for the per-user views collection. 534 * 535 * `update_metadata()` (which `update_user_meta()` wraps) runs `wp_unslash()` 536 * on the value before storing it, while REST JSON params arrive unslashed. 537 * Without the compensating `wp_slash()` any backslash in a saved filter value 538 * (e.g. a name search for `C:\temp`) would be dropped on write — and because 539 * every write rewrites the WHOLE collection, creating, renaming or deleting 540 * one view would strip backslashes from every other view too (#240). 541 * 542 * @param array<int, array<string, mixed>> $views 543 */ 544 private function persist( int $user_id, array $views ): void { 545 update_user_meta( $user_id, 'wcabe_g2_views', wp_slash( $views ) ); 546 } 547 548 /** 522 549 * Ensure only the given view ID is marked as default. 523 550 * -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/readme.txt
r3700717 r3736631 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1. 3.07 Stable tag: 1.4.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 31 31 * **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style 32 32 * **Bulk operations** — apply percentage price adjustments, find-and-replace, text append and prepend, and bulk category and tag assignment across many products at once 33 * **Wide field coverage** — prices, stock, SKUs, slugs, statuses, categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more33 * **Wide field coverage** — prices, stock, SKUs, GTIN / UPC / EAN / ISBN, slugs, statuses, descriptions (with a visual editor), categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more 34 34 * **Saved views** — save filter sets and column layouts as named views for one-click recall, and share them between sites with view import/export 35 * **Filters** — filter by category , tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more35 * **Filters** — filter by category (include or exclude), tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more 36 36 * **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately 37 * **Whole-catalog sorting** — sort any column and the entire catalog is ordered across pages, not just the products on the current page; numbers sort as numbers and empty values stay at the end37 * **Whole-catalog sorting** — sort any column and the entire catalog is ordered across pages, not just the products on the current page; numbers sort as numbers, empty values stay at the end, and variable products rank by their variations 38 38 * **Keyboard shortcuts** — navigate and edit without touching the mouse; arrow keys move between cells even while you are typing 39 39 * **Product operations** — duplicate and change status right from the grid … … 54 54 = Pro version = 55 55 56 If you need more — paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/).56 If you need more — paste from Excel/Google Sheets, an Edit mode that applies one cell edit to every selected product, saved Bulk Edit presets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/). 57 57 58 58 The free version is genuinely useful on its own. Pro is for stores that need more advanced bulk-editing workflows or fields the free version doesn’t touch. … … 134 134 135 135 == Changelog == 136 137 = 1.4.0 = 138 * Added a visual editor for Description and Short Description, with Visual and HTML tabs. Format text, lists, headings, links and alignment without touching HTML; tables, embedded videos and other advanced HTML are kept exactly as they are. 139 * Added a GTIN column for WooCommerce's GTIN, UPC, EAN, or ISBN field on products and variations: edit it inline or in Bulk Edit, sort by it, and export it. Values WooCommerce would reject, such as a GTIN another product already uses, are flagged on the cell. 140 * The Category filter can now exclude categories with "Is none of". CSV export and saved views keep the choice. 141 * Sorting now takes variations into account: a variable product ranks by the lowest or highest value across its variations, and the variations under each product follow the same order, including in CSV exports. 142 * Duplicated products now behave like WooCommerce's own Duplicate: copies get a unique SKU based on the original (ABC-1, ABC-2), start with no reviews, skip fields other plugins mark as "don't copy", and no longer copy the GTIN. Duplicating a variable product many times is much faster. 143 * Pages with "Include variations" on load faster, especially with the Stock filter, and the "Across all pages" count loads separately. 144 * Fixed the grid showing values from before a save on sites where LiteSpeed Cache or another page cache stored the plugin's responses. 145 * Fixed ascending sorts failing with "The response is not a valid JSON response" on sites where another plugin redirects requests containing an `order` parameter, and price sorts taking minutes right after a large import. 146 * Fixed embedded videos, iframes and other HTML being removed from descriptions when an administrator saved them, and description cells showing codes such as ` `. 147 * Fixed category pickers checking every subcategory when you check a parent. Alt+click (Option+click on a Mac) now checks a category together with its subcategories. 148 * Fixed attributes with non-Latin names (for example "Цвет") showing no value in the grid, in variation names and in CSV exports. 149 * Fixed the Stock and Stock quantity filters with "Include variations" hiding matching variations or showing products that don't match. 150 * Fixed filtered CSV exports ignoring the ID and Description filters, and exported attribute headers not matching the grid. 151 * Fixed the grid's filters leaking into product queries run by other plugins while the grid loads. 152 * Pressing Esc to close a popover or dialog no longer deselects your rows, and reopening a category, tag or attribute cell without changes no longer discards its unsaved edit. 153 * Bulk Edit no longer marks products as edited when an operation leaves their value unchanged, and Cost of Goods edits now warn instead of silently doing nothing when WooCommerce's Cost of Goods feature is off. 154 * Fixed variations without their own SKU showing the parent's SKU, new categories being merged into a same-named category under a different parent, and saved views losing backslashes in filter values. 155 * Users without permission to manage products no longer see the plugin's sortable column names in REST API error messages. 136 156 137 157 = 1.3.0 = … … 217 237 == Upgrade Notice == 218 238 239 = 1.4.0 = 240 Visual description editor, GTIN column, Category "Is none of" filter, variation-aware sorting, WooCommerce-style duplicates, page-cache fixes, and many fixes. 241 219 242 = 1.3.0 = 220 243 Whole-catalog sorting on every column, product Slug editing, CSV delimiter choice and grid-order export, arrow-key navigation while editing, YITH bundle support, and many fixes. -
wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/wpmelon-advanced-bulk-edit-for-woocommerce.php
r3700717 r3736631 4 4 * Plugin URI: https://wpmelon.com/bulk-edit/ 5 5 * Description: Excel-like bulk editing interface for WooCommerce products 6 * Version: 1. 3.06 * Version: 1.4.0 7 7 * Author: WPMelon 8 8 * Author URI: https://wpmelon.com/ … … 14 14 * Requires Plugins: woocommerce 15 15 * WC requires at least: 10.0 16 * WC tested up to: 11. 1.016 * WC tested up to: 11.2.0 17 17 * License: GPLv2 or later 18 18 * License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 126 126 define( 'WCABE_G2_LOADED', true ); 127 127 128 define( 'WCABE_G2_VERSION', '1. 3.0' );128 define( 'WCABE_G2_VERSION', '1.4.0' ); 129 129 define( 'WCABE_G2_PLUGIN_FILE', __FILE__ ); 130 130 define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Admin.php
r3700717 r3736631 85 85 'isPro' => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO, 86 86 'cogsEnabled' => Helpers\Product_Helper::is_cogs_enabled(), 87 // Description saves run wp_kses_post only for users without 88 // unfiltered_html (Product_Service::sanitize_description_html()); the 89 // description dialog skips its pre-Apply sanitise preview when true. 90 'canUnfilteredHtml' => current_user_can( 'unfiltered_html' ), 87 91 // Whether this site's woocommerce_stock_amount filter truncates a 88 92 // decimal stock quantity to a whole number (true on a default … … 150 154 151 155 private function enqueue_prod_assets( array $config ): void { 152 $manifest_path = WCABE_G2_PLUGIN_DIR . 'assets/dist/ .vite/manifest.json';156 $manifest_path = WCABE_G2_PLUGIN_DIR . 'assets/dist/manifest.json'; 153 157 154 158 if ( ! file_exists( $manifest_path ) ) { … … 181 185 } 182 186 183 // Enqueue JS. 187 // Enqueue JS. No `ver` query string, deliberately: the lazily loaded 188 // description-editor chunk (#248) imports shared code from this entry by 189 // its bare file URL, and a module loaded as `main-X.js?ver=1.0` is a 190 // DIFFERENT module to the browser — the entry would evaluate a second 191 // time. The Vite content hash in the file name already busts caches. 192 // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion -- content-hashed file name; see above. 184 193 wp_enqueue_script( 185 194 'wcabe-g2-admin', 186 195 $dist_url . $entry['file'], 187 [ 'wp-element', 'wp-i18n', 'wp-api-fetch' ],188 WCABE_G2_VERSION,196 [ 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-autop' ], 197 null, 189 198 true 190 199 ); 200 // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion 191 201 192 202 wp_set_script_translations( -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Handler.php
r3663491 r3736631 35 35 add_filter( 'wcabe_g2_filter_options', [ $this, 'inject_attribute_terms' ] ); 36 36 add_action( 'wcabe_g2_before_format_products', [ $this, 'prime_attribute_cache' ] ); 37 // Attribute_Service memoizes lookups built from the taxonomy registry. 38 add_action( 'registered_taxonomy', [ Attribute_Service::class, 'forget_taxonomy_key' ] ); 39 add_action( 'unregistered_taxonomy', [ Attribute_Service::class, 'forget_taxonomy_key' ] ); 37 40 } 38 41 … … 141 144 $data_key = 'attr_' . $taxonomy; 142 145 143 if ( ! isset( $attributes[ $taxonomy ] ) || ! ( $attributes[ $taxonomy ] instanceof \WC_Product_Attribute ) ) { 146 // WC keys the map by sanitize_title( name ), not the raw taxonomy (#206). 147 $map_key = Attribute_Service::attribute_key( $taxonomy ); 148 149 if ( ! isset( $attributes[ $map_key ] ) || ! ( $attributes[ $map_key ] instanceof \WC_Product_Attribute ) ) { 144 150 $data[ $data_key ] = [ 145 151 'terms' => [], … … 150 156 } 151 157 152 $wc_attr = $attributes[ $ taxonomy ];158 $wc_attr = $attributes[ $map_key ]; 153 159 $term_ids = $wc_attr->get_options(); 154 160 $terms = []; … … 192 198 * 193 199 * For variations, reads $variation->get_attributes() which returns 194 * [ 'pa_color' => 'red' ] (term slugs, not IDs). An empty slug 200 * term slugs (not IDs) keyed by WooCommerce's attribute-map key — 201 * sanitize_title( taxonomy ), e.g. [ 'pa_color' => 'red' ] for ASCII 202 * but [ 'pa_%d1%86%d0%b2%d0%b5%d1%82' => 'krasnyj' ] for `pa_цвет` — 203 * see Attribute_Service::attribute_key() (#206). An empty slug 195 204 * means "Any" — the variation applies to all terms of that attribute. 196 205 * … … 212 221 $taxonomy = $attr['taxonomy']; 213 222 $data_key = 'attr_' . $taxonomy; 214 $slug = $var_attrs[ $taxonomy] ?? '';223 $slug = $var_attrs[ Attribute_Service::attribute_key( $taxonomy ) ] ?? ''; 215 224 216 225 if ( '' === $slug ) { -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Save_Handler.php
r3663491 r3736631 108 108 } 109 109 110 // Fail closed when WooCommerce cannot round-trip this product's attributes: 111 // the save would rewrite them from WC's damaged read. Every attr_pa_* key is 112 // refused, including ones the schema no longer lists, so a stale request or 113 // a history restore gets an error instead of a silent skip. 114 $attr_keys = array_filter( 115 array_keys( $changes ), 116 static fn( $key ) => is_string( $key ) && 0 === strncmp( $key, 'attr_pa_', 8 ) 117 ); 118 if ( ! empty( $attr_keys ) ) { 119 $unsafe = Attribute_Service::unsafe_attribute_labels( $product ); 120 if ( ! empty( $unsafe ) ) { 121 foreach ( $attr_keys as $data_key ) { 122 $errors[] = [ 123 'field' => $data_key, 124 'value' => $changes[ $data_key ], 125 'message' => Attribute_Service::unsafe_attributes_message( $unsafe ), 126 ]; 127 } 128 return $errors; 129 } 130 } 131 110 132 $lookup = self::get_field_lookup(); 111 133 … … 196 218 $attr_def = $lookup[ $data_key ]; 197 219 $taxonomy = $attr_def['taxonomy']; 220 // WC keys the map by sanitize_title( name ), not the raw taxonomy (#206). 221 $map_key = Attribute_Service::attribute_key( $taxonomy ); 198 222 $terms = $value['terms']; 199 223 $visible = isset( $value['visible'] ) ? (bool) $value['visible'] : true; … … 205 229 if ( empty( $term_ids ) ) { 206 230 // No terms — remove the attribute from the product. 207 unset( $attributes[ $ taxonomy ] );231 unset( $attributes[ $map_key ] ); 208 232 $modified = true; 209 233 continue; … … 213 237 // Clone existing attributes so WC's set_prop() detects the change 214 238 // (it compares object identity via !==, not internal state). 215 if ( isset( $attributes[ $ taxonomy ] ) && $attributes[ $taxonomy ] instanceof \WC_Product_Attribute ) {216 $wc_attr = clone $attributes[ $ taxonomy ];239 if ( isset( $attributes[ $map_key ] ) && $attributes[ $map_key ] instanceof \WC_Product_Attribute ) { 240 $wc_attr = clone $attributes[ $map_key ]; 217 241 } else { 218 242 $wc_attr = new \WC_Product_Attribute(); … … 234 258 $wc_attr->set_variation( $variation ); 235 259 236 $attributes[ $ taxonomy ] = $wc_attr;237 $modified = true;260 $attributes[ $map_key ] = $wc_attr; 261 $modified = true; 238 262 } 239 263 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Helpers/Product_Helper.php
r3700717 r3736631 7 7 8 8 namespace WCABE_G2\Helpers; 9 10 use WCABE_G2\Services\Attribute_Service; 9 11 10 12 defined( 'ABSPATH' ) || exit; … … 264 266 'slug' => urldecode( $product->get_slug() ), 265 267 'sku' => $product->get_sku(), 268 // GTIN / UPC / EAN / ISBN (#250). 'edit' context: the stored value, the 269 // same read as the sort (lookup table), history capture and the save. 270 'global_unique_id' => $product->get_global_unique_id( 'edit' ), 266 271 'type' => $product->get_type(), 267 272 'status' => $product->get_status(), … … 407 412 ksort( $attrs ); 408 413 $term_names = []; 409 foreach ( $attrs as $ taxonomy => $slug ) {414 foreach ( $attrs as $key => $slug ) { 410 415 if ( '' === $slug ) { 411 416 continue; // Skip "Any" attributes. 412 417 } 413 $term = get_term_by( 'slug', $slug, $taxonomy ); 418 // Keys are WC's attribute-map keys, not taxonomy names — they differ for 419 // non-Latin attribute names (#245). Null = custom attribute: raw value. 420 $taxonomy = Attribute_Service::taxonomy_for_key( (string) $key ); 421 $term = null !== $taxonomy ? get_term_by( 'slug', $slug, $taxonomy ) : false; 414 422 if ( $term && ! is_wp_error( $term ) ) { 415 423 $term_names[] = $term->name; … … 694 702 // urldecode(): see format_product() — readable form of a percent-encoded slug. 695 703 'slug' => urldecode( $variation->get_slug() ), 696 'sku' => $variation->get_sku(), 704 // 'edit' context: the variation's OWN SKU, empty when it has none. The view 705 // getter inherits the parent SKU whenever the own value is empty() — which 706 // also swallows the valid SKU "0" — so a cleared SKU looked unsaved and the 707 // cell disagreed with the SKU filter, which matches on the own _sku meta 708 // (#118, #178). Same derivation as the ref map in build_product_ref_map() 709 // above and Product_Service::variation_matches_filters(). 710 'sku' => $variation->get_sku( 'edit' ), 711 // A variation's own GTIN — WooCommerce never inherits the parent's (#250). 712 'global_unique_id' => $variation->get_global_unique_id( 'edit' ), 697 713 'type' => 'variation', 698 714 'status' => $variation->get_status(), -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Main.php
r3663491 r3736631 42 42 new Handlers\Debug_Trace_Handler(); 43 43 44 // Page-cache opt-out — keeps cache plugins from serving stale REST responses. 45 new Handlers\Rest_Page_Cache_Handler(); 46 44 47 // WordPress save-hook compatibility — Free feature, controlled by plugin setting. 45 48 new Handlers\WP_Save_Hooks_Handler(); -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Export_Controller.php
r3700717 r3736631 11 11 use WCABE_G2\Helpers\Product_Helper; 12 12 use WCABE_G2\Services\Export_Service; 13 use WCABE_G2\Services\Filter_Service;14 13 use WCABE_G2\Services\Product_Service; 15 14 use WCABE_G2\Services\View_Service; … … 86 85 'sanitize_callback' => 'sanitize_text_field', 87 86 ], 87 'search_description' => [ 88 'description' => __( 'Search by product description.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 89 'type' => 'string', 90 'sanitize_callback' => 'sanitize_text_field', 91 ], 92 'search_short_description' => [ 93 'description' => __( 'Search by product short description.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 94 'type' => 'string', 95 'sanitize_callback' => 'sanitize_text_field', 96 ], 97 // Shared with the products route — its sanitiser must not 98 // HTML-encode `<` / `>` (see Products_Controller::id_operator_arg()). 99 'id_operator' => Products_Controller::id_operator_arg(), 100 'id_value' => [ 101 'description' => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 102 'type' => 'string', 103 'sanitize_callback' => 'sanitize_text_field', 104 ], 88 105 'product_url_operator' => [ 89 106 'description' => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 104 121 'sanitize_callback' => 'sanitize_text_field', 105 122 ], 123 'category_operator' => Products_Controller::category_operator_arg(), 106 124 'tag' => [ 107 125 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 134 152 'type' => 'string', 135 153 ], 136 'price_min' => [ 137 'description' => __( 'Minimum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 138 'type' => 'string', 139 'sanitize_callback' => 'sanitize_text_field', 140 ], 141 'price_max' => [ 142 'description' => __( 'Maximum price.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 143 'type' => 'string', 144 'sanitize_callback' => 'sanitize_text_field', 145 ], 146 'stock_qty_min' => [ 147 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 148 'type' => 'string', 149 'sanitize_callback' => 'sanitize_text_field', 150 ], 151 'stock_qty_max' => [ 152 'description' => __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 153 'type' => 'string', 154 'sanitize_callback' => 'sanitize_text_field', 155 ], 154 // Numeric range bounds share the grid's definitions so a non-numeric 155 // value is a 400 here too (see Products_Controller::numeric_filter_arg()). 156 'price_min' => Products_Controller::numeric_filter_arg( 157 __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 158 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 159 ), 160 'price_max' => Products_Controller::numeric_filter_arg( 161 __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 162 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 163 ), 164 'stock_qty_min' => Products_Controller::numeric_filter_arg( 165 __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 166 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 167 ), 168 'stock_qty_max' => Products_Controller::numeric_filter_arg( 169 __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 170 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 171 ), 156 172 'menu_order_min' => [ 157 173 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 182 198 }, 183 199 ], 184 'cogs_min' => [ 185 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 186 'type' => 'string', 187 'sanitize_callback' => 'sanitize_text_field', 188 ], 189 'cogs_max' => [ 190 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 191 'type' => 'string', 192 'sanitize_callback' => 'sanitize_text_field', 193 ], 200 'cogs_min' => Products_Controller::numeric_filter_arg( 201 __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 202 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 203 ), 204 'cogs_max' => Products_Controller::numeric_filter_arg( 205 __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 206 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 207 ), 194 208 'manage_stock' => [ 195 209 'description' => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 291 305 'validate_callback' => [ Products_Controller::class, 'validate_orderby' ], 292 306 ], 293 'order' => [ 294 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 295 'type' => 'string', 296 'default' => 'desc', 297 'enum' => [ 'asc', 'desc' ], 298 // Same reason as `delimiter` below: a hand-registered arg gets no 299 // default validate_callback, so the enum needs this to be enforced. 300 'validate_callback' => 'rest_validate_request_arg', 301 'sanitize_callback' => 'sanitize_text_field', 302 ], 307 // Shared with the products and variations routes — see 308 // Products_Controller::sort_dir_arg(). 309 Products_Controller::SORT_DIR_PARAM => Products_Controller::sort_dir_arg(), 310 'order' => Products_Controller::legacy_order_arg(), 303 311 'delimiter' => [ 304 312 'description' => __( 'CSV field delimiter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 460 468 // order, which the client already builds from the grid's display order (#225). 461 469 $orderby = (string) $request->get_param( 'orderby' ); 462 $order = (string) $request->get_param( 'order');470 $order = Products_Controller::resolve_sort_direction( $request ); 463 471 464 472 $units = [ … … 513 521 514 522 /** 515 * Extract filter parameters from the request. 516 * 517 * Protected rather than private as a TEST SEAM: export() itself ends in 523 * The export's filter set: the grid's own builder, so a filtered export 524 * applies exactly the filters the grid applies. A hand-kept copy here once 525 * silently dropped the ID and description filters (#275). 526 * 527 * Protected rather than inlined as a TEST SEAM: export() itself ends in 518 528 * exit(), so it cannot run under PHPUnit — a test subclass exposes this 519 529 * method instead, which is what keeps the filter→CSV bridge covered. … … 523 533 */ 524 534 protected function extract_filters( \WP_REST_Request $request ): array { 525 $filters = []; 526 527 $search_name = $request->get_param( 'search_name' ); 528 if ( ! empty( $search_name ) ) { 529 $filters['search_name'] = sanitize_text_field( $search_name ); 530 } 531 $search_name_mode = $request->get_param( 'search_name_mode' ); 532 if ( in_array( $search_name_mode, Product_Service::TITLE_SEARCH_MODES, true ) ) { 533 $filters['search_name_mode'] = $search_name_mode; 534 } 535 // Strict '' test, not empty(): "0" is a valid SKU. 536 $search_sku = sanitize_text_field( (string) $request->get_param( 'search_sku' ) ); 537 if ( '' !== $search_sku ) { 538 $filters['search_sku'] = $search_sku; 539 } 540 $search_sku_mode = $request->get_param( 'search_sku_mode' ); 541 if ( in_array( $search_sku_mode, Product_Service::SKU_SEARCH_MODES, true ) ) { 542 $filters['search_sku_mode'] = $search_sku_mode; 543 } 544 545 // Product URL filter. Deliberately NOT gated on a non-empty value — the 546 // is_empty / is_not_empty operators are effective with none. 547 // Mirrors Products_Controller::get_items(); keep the two in step. 548 $product_url_operator = $request->get_param( 'product_url_operator' ) ?: 'contains'; 549 $product_url_value = View_Service::sanitize_url_filter_value( $request->get_param( 'product_url_value' ) ?? '' ); 550 if ( Product_Service::product_url_filter_is_effective( $product_url_operator, $product_url_value ) ) { 551 $filters['product_url_operator'] = $product_url_operator; 552 $filters['product_url_value'] = $product_url_value; 553 } 554 555 // Term-ID filters. A param that is PRESENT but parses to zero valid IDs must match 556 // NOTHING (the [0] sentinel — no term has ID 0), not drop the filter: an export 557 // that silently widens back to the whole catalog is the worse failure. The presence 558 // test is deliberately `'' !==` rather than `! empty()` — empty('0') is true, so the 559 // single most likely corrupt value would otherwise slip through as "no filter". 560 // Mirrors Products_Controller::get_items(); keep the two in step. 561 $category = $request->get_param( 'category' ); 562 if ( null !== $category && '' !== $category ) { 563 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 564 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 565 } 566 567 $tag = $request->get_param( 'tag' ); 568 if ( null !== $tag && '' !== $tag ) { 569 $tag_ids = array_filter( array_map( 'absint', explode( ',', $tag ) ) ); 570 $filters['tag'] = empty( $tag_ids ) ? [ 0 ] : $tag_ids; 571 } 572 573 $type = $request->get_param( 'type' ); 574 if ( ! empty( $type ) ) { 575 $filters['type'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $type ) ) ); 576 } 577 578 $status = $request->get_param( 'status' ); 579 if ( ! empty( $status ) ) { 580 $filters['status'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $status ) ) ); 581 } 582 583 $stock_status = $request->get_param( 'stock_status' ); 584 if ( ! empty( $stock_status ) ) { 585 $filters['stock_status'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $stock_status ) ) ); 586 } 587 588 $filters = Filter_Service::apply_shipping_class_param( 589 $request->get_param( 'shipping_class' ), 590 $filters 591 ); 592 593 $price_min = $request->get_param( 'price_min' ); 594 if ( null !== $price_min && '' !== $price_min ) { 595 $filters['price_min'] = sanitize_text_field( $price_min ); 596 } 597 598 $price_max = $request->get_param( 'price_max' ); 599 if ( null !== $price_max && '' !== $price_max ) { 600 $filters['price_max'] = sanitize_text_field( $price_max ); 601 } 602 603 // Cost of Goods range — gated so a stale param can't trigger the COGS query 604 // path when the feature is off (matches Products_Controller). 605 if ( Product_Helper::is_cogs_enabled() ) { 606 $cogs_min = $request->get_param( 'cogs_min' ); 607 if ( null !== $cogs_min && '' !== $cogs_min ) { 608 $filters['cogs_min'] = sanitize_text_field( $cogs_min ); 609 } 610 611 $cogs_max = $request->get_param( 'cogs_max' ); 612 if ( null !== $cogs_max && '' !== $cogs_max ) { 613 $filters['cogs_max'] = sanitize_text_field( $cogs_max ); 614 } 615 } 616 617 $stock_qty_min = $request->get_param( 'stock_qty_min' ); 618 if ( null !== $stock_qty_min && '' !== $stock_qty_min ) { 619 $filters['stock_qty_min'] = sanitize_text_field( $stock_qty_min ); 620 } 621 622 $stock_qty_max = $request->get_param( 'stock_qty_max' ); 623 if ( null !== $stock_qty_max && '' !== $stock_qty_max ) { 624 $filters['stock_qty_max'] = sanitize_text_field( $stock_qty_max ); 625 } 626 627 $menu_order_min = $request->get_param( 'menu_order_min' ); 628 if ( null !== $menu_order_min && '' !== $menu_order_min ) { 629 $filters['menu_order_min'] = sanitize_text_field( $menu_order_min ); 630 } 631 632 $menu_order_max = $request->get_param( 'menu_order_max' ); 633 if ( null !== $menu_order_max && '' !== $menu_order_max ) { 634 $filters['menu_order_max'] = sanitize_text_field( $menu_order_max ); 635 } 636 637 $manage_stock = $request->get_param( 'manage_stock' ); 638 if ( ! empty( $manage_stock ) ) { 639 $filters['manage_stock'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $manage_stock ) ) ); 640 } 641 642 $backorders = $request->get_param( 'backorders' ); 643 if ( ! empty( $backorders ) ) { 644 $filters['backorders'] = array_filter( array_map( 'sanitize_text_field', explode( ',', $backorders ) ) ); 645 } 646 647 $date_params = [ 648 'date_created_after', 649 'date_created_before', 650 'date_modified_after', 651 'date_modified_before', 652 'sale_start_after', 653 'sale_start_before', 654 'sale_end_after', 655 'sale_end_before', 656 'image_date_after', 657 'image_date_before', 658 ]; 659 foreach ( $date_params as $param ) { 660 $val = $request->get_param( $param ); 661 if ( ! empty( $val ) ) { 662 $filters[ $param ] = sanitize_text_field( $val ); 663 } 664 } 665 666 $attribute_filter = $request->get_param( 'attribute_filter' ); 667 if ( is_array( $attribute_filter ) && ! empty( $attribute_filter ) ) { 668 $sanitized = []; 669 foreach ( $attribute_filter as $slug => $ids_string ) { 670 // sanitize_text_field(), not sanitize_key(): WC registers non-Latin 671 // attribute names verbatim (e.g. `pa_цвет`), which sanitize_key() 672 // would collapse to `pa_`. This enforces the pa_* shape only; 673 // existence is gated in Product_Service via taxonomy_exists(). 674 $sanitized_slug = sanitize_text_field( (string) $slug ); 675 // Only allow pa_* taxonomy slugs. 676 if ( strncmp( $sanitized_slug, 'pa_', 3 ) !== 0 ) { 677 continue; 678 } 679 $ids = array_filter( array_map( 'absint', explode( ',', (string) $ids_string ) ) ); 680 if ( ! empty( $ids ) ) { 681 $sanitized[ $sanitized_slug ] = $ids; 682 } 683 } 684 if ( ! empty( $sanitized ) ) { 685 $filters['attribute_filter'] = $sanitized; 686 } 687 } 688 689 /** 690 * Allow extensions (Pro) to add filter slots from the request. 691 * 692 * @param array $filters Sanitized filter array. 693 * @param \WP_REST_Request $request Incoming request. 694 */ 695 return apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 535 return Products_Controller::build_filters( $request ); 696 536 } 697 537 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Products_Controller.php
r3700717 r3736631 8 8 namespace WCABE_G2\REST; 9 9 10 use WCABE_G2\Helpers\Html_Loss_Detector; 10 11 use WCABE_G2\Helpers\Page_Size_Helper; 11 12 use WCABE_G2\Helpers\Permission_Helper; … … 26 27 const BATCH_LIMIT = 100; 27 28 29 /** 30 * Request param carrying the server-sort direction. Deliberately not `order`: 31 * third-party code on real sites treats any `?order=<x>` as an order id and 32 * redirects the request to its edit screen, so an ascending sort came back as 33 * wp-admin HTML (#254). `order` stays accepted as a legacy alias — see 34 * resolve_sort_direction(). 35 */ 36 const SORT_DIR_PARAM = 'sort_dir'; 37 28 38 protected $namespace = 'wcabe/g2'; 29 39 protected $rest_base = 'products'; … … 44 54 45 55 public function register_routes(): void { 56 // Built once: it runs `wcabe_g2_rest_query_args` on every REST request. 57 $collection_params = $this->get_collection_params(); 58 46 59 register_rest_route( $this->namespace, '/' . $this->rest_base, [ 47 60 [ … … 49 62 'callback' => [ $this, 'get_items' ], 50 63 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 51 'args' => $this->get_collection_params(), 64 'args' => $collection_params, 65 ], 66 ] ); 67 68 // The "with variations" row count for a filter set (#274), split from the 69 // products list so page and sort changes do not re-run it. 70 register_rest_route( $this->namespace, '/' . $this->rest_base . '/variation-count', [ 71 [ 72 'methods' => \WP_REST_Server::READABLE, 73 'callback' => [ $this, 'get_variation_count' ], 74 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 75 'args' => $this->get_filter_params( $collection_params ), 76 ], 77 ] ); 78 79 // Pre-Apply preview for the description dialog (#248): runs the same 80 // sanitiser the save runs, as the requesting user, so the dialog can list 81 // what WordPress will remove before the value is staged. Read-only. 82 register_rest_route( $this->namespace, '/' . $this->rest_base . '/sanitize-html', [ 83 [ 84 'methods' => \WP_REST_Server::CREATABLE, 85 'callback' => [ $this, 'sanitize_html' ], 86 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 87 'args' => [ 88 // No sanitize_callback: the raw submitted HTML is the input under test. 89 'value' => [ 90 'description' => __( 'Description HTML to sanitise.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 91 'required' => true, 92 'type' => 'string', 93 'validate_callback' => 'rest_validate_request_arg', 94 ], 95 ], 52 96 ], 53 97 ] ); … … 194 238 195 239 /** 240 * Preview what saving this description HTML would store (#248). 241 * 196 242 * @param \WP_REST_Request $request 197 * @return \WP_REST_Response 198 */ 199 public function get_items( $request ): \WP_REST_Response { 243 * @return \WP_REST_Response `{ sanitized, losses: [{ type, tag, attribute, detail, message }] }`. 244 */ 245 public function sanitize_html( $request ): \WP_REST_Response { 246 $result = Product_Service::sanitize_description_html( (string) $request->get_param( 'value' ) ); 247 248 $losses = []; 249 foreach ( $result['losses'] as $loss ) { 250 $losses[] = $loss + [ 'message' => Html_Loss_Detector::describe( [ $loss ] )[0] ]; 251 } 252 253 return new \WP_REST_Response( 254 [ 255 'sanitized' => $result['value'], 256 'losses' => $losses, 257 ], 258 200 259 ); 260 } 261 262 /** 263 * @param \WP_REST_Request $request 264 * @return \WP_REST_Response|\WP_Error 265 */ 266 public function get_items( $request ) { 200 267 $this->raise_resource_limits(); 201 268 … … 206 273 $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) ); 207 274 $include_variations = (bool) $request->get_param( 'include_variations' ); 208 209 // Build filters array from request params. 275 $filters = self::build_filters( $request ); 276 277 // Server-side sort. Whitelisted by validate_orderby() against the service's 278 // sort-spec table (Product_Service::get_sortable_columns()) at request time; 279 // the service applies it through a posts_clauses ORDER BY override. 280 $orderby = (string) $request->get_param( 'orderby' ); 281 $order = self::resolve_sort_direction( $request ); 282 283 $service = new Product_Service(); 284 try { 285 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 286 } catch ( \RuntimeException $e ) { 287 return self::query_scope_lost_error( $e ); 288 } 289 290 $response = rest_ensure_response( $data ); 291 $response->header( 'X-WP-Total', (string) $data['total'] ); 292 $response->header( 'X-WP-TotalPages', (string) $data['total_pages'] ); 293 294 return $response; 295 } 296 297 /** 298 * The variation rows the grid adds across all matching pages when variations 299 * are interleaved (#274). Split from get_items() because it re-runs the 300 * filtered query unpaginated: the client asks once per filter set, not once 301 * per page or sort. 302 * 303 * `variation_count` is null when the `wcabe_g2_compute_variation_total` filter 304 * switches the count off. 305 * 306 * @param \WP_REST_Request $request 307 * @return \WP_REST_Response|\WP_Error 308 */ 309 public function get_variation_count( $request ) { 310 $this->raise_resource_limits(); 311 312 $service = new Product_Service(); 313 try { 314 $count = $service->get_variation_count( self::build_filters( $request ) ); 315 } catch ( \RuntimeException $e ) { 316 return self::query_scope_lost_error( $e ); 317 } 318 319 return rest_ensure_response( [ 320 'variation_count' => null === $count ? null : $count['variation_count'], 321 'is_approximate' => null === $count ? false : $count['is_approximate'], 322 ] ); 323 } 324 325 /** 326 * The service refuses to return a result set whose filters/sort it could not 327 * enforce (its query marker was stripped by another plugin). Surface that as an 328 * explicit error instead of a PHP fatal, so the grid shows a message rather 329 * than an unfiltered catalogue or a blank 500. 330 * 331 * @param \RuntimeException $e 332 * @return \WP_Error 333 */ 334 private static function query_scope_lost_error( \RuntimeException $e ): \WP_Error { 335 return new \WP_Error( 336 'wcabe_g2_query_scope_lost', 337 __( 'The product query could not be filtered because another plugin altered it. Check for plugins that rewrite WooCommerce product queries.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 338 [ 339 'status' => 500, 340 'detail' => $e->getMessage(), 341 ] 342 ); 343 } 344 345 /** 346 * Build the service's filters array from the request params. The one reader 347 * of filter params: the products list, the variation count and the CSV export 348 * (Export_Controller::extract_filters()) all call it, so all three apply the 349 * same filter set. The export once kept its own copy, which silently dropped 350 * the ID and description filters (#275). Static so the export controller can 351 * call it without an instance. 352 * 353 * @param \WP_REST_Request $request 354 * @return array 355 */ 356 public static function build_filters( \WP_REST_Request $request ): array { 210 357 $filters = []; 211 358 … … 240 387 // Do not apply sanitize_text_field to id_operator — it HTML-encodes 241 388 // `<` and `>`, which breaks the operator whitelist in Product_Service. 242 // The REST schema's enum already validates the value. 389 // The schema enum is NOT enforced (the arg has its own sanitize_callback 390 // and no validate_callback); Product_Service's whitelist is what rejects 391 // an unknown operator, falling back to '='. 243 392 $filters['id_operator'] = $request->get_param( 'id_operator' ) ?: '='; 244 393 $filters['id_value'] = sanitize_text_field( $id_value ); … … 263 412 $category = $request->get_param( 'category' ); 264 413 if ( null !== $category && '' !== $category ) { 265 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 266 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 414 $category_ids = array_filter( array_map( 'absint', explode( ',', $category ) ) ); 415 $filters['category'] = empty( $category_ids ) ? [ 0 ] : $category_ids; 416 $filters['category_operator'] = $request->get_param( 'category_operator' ) ?: 'in'; 267 417 } 268 418 … … 395 545 * @param \WP_REST_Request $request Incoming request. 396 546 */ 397 $filters = apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 398 399 // Server-side sort. Whitelisted by validate_orderby() against the service's 400 // sort-spec table (Product_Service::get_sortable_columns()) at request time; 401 // the service applies it through a posts_clauses ORDER BY override. 402 $orderby = (string) $request->get_param( 'orderby' ); 403 $order = (string) $request->get_param( 'order' ); 404 405 $service = new Product_Service(); 406 $data = $service->get_products( $page, $per_page, $include_variations, $filters, $orderby, $order ); 407 408 $response = rest_ensure_response( $data ); 409 $response->header( 'X-WP-Total', (string) $data['total'] ); 410 $response->header( 'X-WP-TotalPages', (string) $data['total_pages'] ); 411 412 return $response; 547 return apply_filters( 'wcabe_g2_rest_request_filters', $filters, $request ); 413 548 } 414 549 … … 510 645 * purpose — see the arg definition in get_collection_params(). 511 646 * 647 * Shared by the products, variations and export routes. WordPress validates 648 * args BEFORE it runs `permission_callback` (WP_REST_Server::dispatch() → 649 * has_valid_params(), and respond_to_request() skips the permission check 650 * once validation has failed), so for a caller without the capability this 651 * defers to the route's Permission_Helper::rest_permission_check() — the 652 * same can_manage() test — which answers 403. Otherwise a logged-out request 653 * would get the 400 below, listing every sortable id (Pro's ACF field names 654 * and custom meta keys included), and would run the spec derivation. 655 * 512 656 * @param mixed $value Raw request value. 513 657 * @param \WP_REST_Request $request Incoming request. … … 516 660 */ 517 661 public static function validate_orderby( $value, \WP_REST_Request $request, string $param ) { 662 if ( ! Permission_Helper::can_manage() ) { 663 return true; 664 } 665 518 666 $columns = Product_Service::get_sortable_columns(); 519 667 if ( is_string( $value ) && in_array( $value, $columns, true ) ) { … … 529 677 ), 530 678 [ 'status' => 400 ] 679 ); 680 } 681 682 /** 683 * The server-sort direction of a products / variations / export request: 684 * SORT_DIR_PARAM when the caller sent a value, else the legacy `order` (#254). 685 * 686 * "Sent a value" is a non-empty string, not has_param(): an explicit JSON 687 * `null` skips WordPress's validation (it only validates non-null params) and 688 * is then sanitized to '', and must not shadow a valid `order`. SORT_DIR_PARAM 689 * has NO default on any route (see sort_dir_arg()) — a default would make it 690 * read non-empty on every request and always shadow `order`, which keeps its 691 * 'desc' default. Outside a real dispatch (tests calling a controller with a 692 * raw request) neither has a default and this returns '', which the service 693 * reads as the default sort. 694 * 695 * @param \WP_REST_Request $request Incoming request. 696 * @return string 'asc', 'desc', or '' when neither param carries a value. 697 */ 698 public static function resolve_sort_direction( \WP_REST_Request $request ): string { 699 $direction = self::sent_sort_dir( $request ); 700 return '' !== $direction ? $direction : (string) $request->get_param( 'order' ); 701 } 702 703 /** 704 * The caller's SORT_DIR_PARAM value, or '' when it carries none (absent, null, 705 * empty, or not a string — the last two are rejected by its own validation). 706 * 707 * @param \WP_REST_Request $request Incoming request. 708 * @return string 709 */ 710 private static function sent_sort_dir( \WP_REST_Request $request ): string { 711 $value = $request->get_param( self::SORT_DIR_PARAM ); 712 return is_string( $value ) ? $value : ''; 713 } 714 715 /** 716 * Arg definition of `category_operator`, shared by the products and export routes 717 * (#147). NO `default`: the controllers read it only next to a present `category` 718 * and fall back to 'in' themselves. Validation is wired explicitly because an arg 719 * with its own sanitize_callback gets no schema validation from core, so an unknown 720 * mode is a 400 rather than a silent flip to the other set. 721 * 722 * @return array 723 */ 724 public static function category_operator_arg(): array { 725 return [ 726 'description' => __( 'Category filter mode: in (products in any of the categories, the default) or not_in (products in none of them).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 727 'type' => 'string', 728 'enum' => Product_Service::CATEGORY_OPERATORS, 729 'validate_callback' => 'rest_validate_request_arg', 730 'sanitize_callback' => 'sanitize_text_field', 731 ]; 732 } 733 734 /** 735 * Arg definition of `id_operator`, shared by the products and export routes 736 * (#275) so its sanitiser cannot drift between them: sanitize_text_field() 737 * HTML-encodes `<` and `>`, which breaks the operator whitelist match in 738 * Product_Service, so strings pass through untouched. 739 * 740 * @return array 741 */ 742 public static function id_operator_arg(): array { 743 return [ 744 'description' => __( 'ID filter operator: =, >, <, >=, <=, or expression.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 745 'type' => 'string', 746 'default' => '=', 747 'enum' => [ '=', '>', '<', '>=', '<=', 'expression' ], 748 'sanitize_callback' => static function ( $value ) { 749 return is_string( $value ) ? $value : '='; 750 }, 751 ]; 752 } 753 754 /** 755 * Arg definition of a numeric range bound (price, COGS, stock quantity), 756 * shared by the products and export routes (#279). The export once declared 757 * these without a validator, so a non-numeric bound the grid rejects reached 758 * Product_Service there and was coerced: (float) 'abc' is a COGS / stock 759 * bound of 0, and wc_format_decimal() strips a price bound's non-digits. 760 * 761 * @param string $description Arg description. 762 * @param string $invalid_message Error message for a non-numeric value. 763 * @return array 764 */ 765 public static function numeric_filter_arg( string $description, string $invalid_message ): array { 766 return [ 767 'description' => $description, 768 'type' => 'string', 769 'sanitize_callback' => 'sanitize_text_field', 770 'validate_callback' => static function ( $value ) use ( $invalid_message ) { 771 if ( '' === $value ) { 772 return true; 773 } 774 return is_numeric( $value ) ? true : new \WP_Error( 'rest_invalid_param', $invalid_message ); 775 }, 776 ]; 777 } 778 779 /** 780 * Arg definition of SORT_DIR_PARAM, shared by the products, variations and 781 * export routes so the no-default rule cannot drift between them. 782 * 783 * NO `default`: resolve_sort_direction() falls back to the legacy `order` only 784 * while SORT_DIR_PARAM reads empty. Validation is wired explicitly because an 785 * arg with its own sanitize_callback gets no schema validation from core. 786 * 787 * @return array 788 */ 789 public static function sort_dir_arg(): array { 790 return [ 791 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 792 'type' => 'string', 793 'enum' => [ 'asc', 'desc' ], 794 'validate_callback' => 'rest_validate_request_arg', 795 'sanitize_callback' => 'sanitize_text_field', 796 ]; 797 } 798 799 /** 800 * Arg definition of the legacy `order` alias, shared by the same three routes. 801 * 802 * @return array 803 */ 804 public static function legacy_order_arg(): array { 805 return [ 806 'description' => __( 'Deprecated alias of sort_dir, used only when sort_dir is absent.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 807 'type' => 'string', 808 'default' => 'desc', 809 'enum' => [ 'asc', 'desc' ], 810 'validate_callback' => [ self::class, 'validate_legacy_order' ], 811 'sanitize_callback' => 'sanitize_text_field', 812 ]; 813 } 814 815 /** 816 * `order` validate_callback: the enum is enforced only while `order` is the 817 * direction in use. When the caller also sent SORT_DIR_PARAM, `order` is 818 * ignored by resolve_sort_direction(), so a stray value — e.g. third-party 819 * code appending an `order` of its own, the #254 conflict class — must not 820 * 400 an otherwise valid request. An invalid SORT_DIR_PARAM still fails on 821 * its own validation. 822 * 823 * @param mixed $value Raw request value. 824 * @param \WP_REST_Request $request Incoming request. 825 * @param string $param Parameter name. 826 * @return true|\WP_Error 827 */ 828 public static function validate_legacy_order( $value, \WP_REST_Request $request, string $param ) { 829 if ( '' !== self::sent_sort_dir( $request ) ) { 830 return true; 831 } 832 return rest_validate_request_arg( $value, $request, $param ); 833 } 834 835 /** 836 * The collection params minus paging, sort and include_variations: the filter 837 * args alone, including any an extension adds through `wcabe_g2_rest_query_args`. 838 * Used by the variation count, whose result depends on the filters only. 839 * 840 * @param array|null $collection_params get_collection_params(), when the caller 841 * already built it. 842 * @return array 843 */ 844 public function get_filter_params( ?array $collection_params = null ): array { 845 return array_diff_key( 846 $collection_params ?? $this->get_collection_params(), 847 array_flip( [ 'page', 'per_page', 'include_variations', 'orderby', self::SORT_DIR_PARAM, 'order' ] ) 531 848 ); 532 849 } … … 578 895 'validate_callback' => [ self::class, 'validate_orderby' ], 579 896 ], 580 'order' => [ 581 'description' => __( 'Server-side sort direction.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 582 'type' => 'string', 583 'default' => 'desc', 584 'enum' => [ 'asc', 'desc' ], 585 ], 897 // Shared with the variations and export routes — see sort_dir_arg(). 898 self::SORT_DIR_PARAM => self::sort_dir_arg(), 899 'order' => self::legacy_order_arg(), 586 900 'search_name' => [ 587 901 'description' => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 619 933 'sanitize_callback' => 'sanitize_text_field', 620 934 ], 621 'id_operator' => [ 622 'description' => __( 'ID filter operator: =, >, <, >=, <=, or expression.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 623 'type' => 'string', 624 'default' => '=', 625 'enum' => [ '=', '>', '<', '>=', '<=', 'expression' ], 626 'sanitize_callback' => function ( $value ) { 627 // sanitize_text_field HTML-encodes `<` and `>`, which breaks the 628 // operator whitelist match in Product_Service. The enum above 629 // already validates input, so we just pass strings through. 630 return is_string( $value ) ? $value : '='; 631 }, 632 ], 935 'id_operator' => self::id_operator_arg(), 633 936 'id_value' => [ 634 937 'description' => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 655 958 'sanitize_callback' => 'sanitize_text_field', 656 959 ], 960 'category_operator' => self::category_operator_arg(), 657 961 'tag' => [ 658 962 'description' => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), … … 685 989 'type' => 'string', 686 990 ], 687 'price_min' => [ 688 'description' => __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 689 'type' => 'string', 690 'sanitize_callback' => 'sanitize_text_field', 691 'validate_callback' => function( $value ) { 692 if ( '' === $value ) { 693 return true; 694 } 695 return is_numeric( $value ) ? true : new \WP_Error( 696 'rest_invalid_param', 697 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 698 ); 699 }, 700 ], 701 'price_max' => [ 702 'description' => __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 703 'type' => 'string', 704 'sanitize_callback' => 'sanitize_text_field', 705 'validate_callback' => function( $value ) { 706 if ( '' === $value ) { 707 return true; 708 } 709 return is_numeric( $value ) ? true : new \WP_Error( 710 'rest_invalid_param', 711 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 712 ); 713 }, 714 ], 715 'cogs_min' => [ 716 'description' => __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 717 'type' => 'string', 718 'sanitize_callback' => 'sanitize_text_field', 719 'validate_callback' => function( $value ) { 720 if ( '' === $value ) { 721 return true; 722 } 723 return is_numeric( $value ) ? true : new \WP_Error( 724 'rest_invalid_param', 725 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 726 ); 727 }, 728 ], 729 'cogs_max' => [ 730 'description' => __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 731 'type' => 'string', 732 'sanitize_callback' => 'sanitize_text_field', 733 'validate_callback' => function( $value ) { 734 if ( '' === $value ) { 735 return true; 736 } 737 return is_numeric( $value ) ? true : new \WP_Error( 738 'rest_invalid_param', 739 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 740 ); 741 }, 742 ], 743 'stock_qty_min' => [ 744 'description' => __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 745 'type' => 'string', 746 'sanitize_callback' => 'sanitize_text_field', 747 'validate_callback' => function( $value ) { 748 if ( '' === $value ) { 749 return true; 750 } 751 return is_numeric( $value ) ? true : new \WP_Error( 752 'rest_invalid_param', 753 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 754 ); 755 }, 756 ], 757 'stock_qty_max' => [ 758 'description' => __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 759 'type' => 'string', 760 'sanitize_callback' => 'sanitize_text_field', 761 'validate_callback' => function( $value ) { 762 if ( '' === $value ) { 763 return true; 764 } 765 return is_numeric( $value ) ? true : new \WP_Error( 766 'rest_invalid_param', 767 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 768 ); 769 }, 770 ], 991 'price_min' => self::numeric_filter_arg( 992 __( 'Minimum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 993 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 994 ), 995 'price_max' => self::numeric_filter_arg( 996 __( 'Maximum price filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 997 __( 'Price must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 998 ), 999 'cogs_min' => self::numeric_filter_arg( 1000 __( 'Minimum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1001 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1002 ), 1003 'cogs_max' => self::numeric_filter_arg( 1004 __( 'Maximum Cost of Goods filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1005 __( 'Cost of Goods must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1006 ), 1007 'stock_qty_min' => self::numeric_filter_arg( 1008 __( 'Minimum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1009 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1010 ), 1011 'stock_qty_max' => self::numeric_filter_arg( 1012 __( 'Maximum stock quantity filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1013 __( 'Stock quantity must be numeric.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 1014 ), 771 1015 'menu_order_min' => [ 772 1016 'description' => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/User_Settings_Controller.php
r3700717 r3736631 77 77 'enum' => [ 'adaptive', '25', '50', '100' ], 78 78 ], 79 // Last-used description dialog tab (#248). Mirrors 80 // DESCRIPTION_EDITOR_MODES in assets/src/types/api.ts. 81 'descriptionEditorMode' => [ 82 'type' => 'string', 83 'default' => 'visual', 84 'enum' => [ 'visual', 'html' ], 85 ], 79 86 ]; 80 87 } … … 204 211 } 205 212 206 update_user_meta( get_current_user_id(), self::META_KEY, $current ); 213 // wp_slash() cancels update_metadata()'s wp_unslash(); REST JSON params are 214 // unslashed, so a backslash in the filter draft would otherwise be dropped on 215 // every write (#240). 216 update_user_meta( get_current_user_id(), self::META_KEY, wp_slash( $current ) ); 207 217 208 218 // Return SCHEMA-only settings (get_all strips _rev) so _rev never surfaces -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Variations_Controller.php
r3700717 r3736631 27 27 'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ], 28 28 'args' => [ 29 'id' => [29 'id' => [ 30 30 'description' => __( 'Product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 31 31 'type' => 'integer', … … 33 33 'sanitize_callback' => 'absint', 34 34 ], 35 // The grid's active sort (#247): the expanded variations are 36 // ordered exactly as they would be interleaved under the parent. 37 // Same request-time validation as the products route — see the 38 // note on Products_Controller::validate_orderby(). 39 'orderby' => [ 40 'description' => __( 'Order the variations by this column.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 41 'type' => 'string', 42 'default' => 'id', 43 'validate_callback' => [ Products_Controller::class, 'validate_orderby' ], 44 ], 45 // Shared with the products and export routes — see 46 // Products_Controller::sort_dir_arg(). 47 Products_Controller::SORT_DIR_PARAM => Products_Controller::sort_dir_arg(), 48 'order' => Products_Controller::legacy_order_arg(), 35 49 ], 36 50 ], … … 48 62 49 63 $product_id = absint( $request->get_param( 'id' ) ); 64 $orderby = (string) $request->get_param( 'orderby' ); 65 $order = Products_Controller::resolve_sort_direction( $request ); 50 66 51 67 $service = new Product_Service(); 52 $result = $service->get_variations( $product_id);68 $result = $service->get_variations( $product_id, $orderby, $order ); 53 69 54 70 if ( is_wp_error( $result ) ) { -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Attribute_Service.php
r3549389 r3736631 19 19 private static $schema_cache = null; 20 20 21 /** 22 * Request-level memo for attribute_key(), raw taxonomy => map key. 23 * 24 * @var array<string, string> 25 */ 26 private static $attribute_keys = []; 27 28 /** 29 * Request-level memo for taxonomy_for_key(), attribute-map key => registered 30 * pa_* taxonomy name. Null until first needed; forget_taxonomy_key() drops it 31 * whenever a pa_* taxonomy is (un)registered. 32 * 33 * @var array<string, string>|null 34 */ 35 private static $taxonomies_by_key = null; 36 37 /** 38 * Request-level memo for attribute_registry_hazards(). Null until first needed; 39 * dropped by clear_cache() and forget_taxonomy_key(). 40 * 41 * @var array{unsafe: array<string, string>, unregistered: string[]}|null 42 */ 43 private static $registry_hazards = null; 44 21 45 // TODO: Custom per-product text attributes (non-taxonomy) are not supported. 22 46 // Global taxonomy-backed attributes only. Per-product attributes are a different … … 27 51 * 28 52 * Returns every attribute registered via WooCommerce > Attributes admin. 29 * No settings/enable step — all discovered attributes are included. 53 * No settings/enable step — all discovered attributes are included, except 54 * ones WooCommerce cannot currently round-trip (unregistered, or flagged by 55 * attribute_registry_hazards()): a column for those would feed edits into a 56 * save that converts or drops them. In a normal setup nothing is excluded. 30 57 * 31 58 * @return array Array of [ 'slug' => 'color', 'label' => 'Color', 'taxonomy' => 'pa_color', 'hierarchical' => false, 'orderby' => 'menu_order' ]. … … 42 69 } 43 70 71 $unsafe = self::attribute_registry_hazards()['unsafe']; 44 72 $result = []; 45 73 46 74 foreach ( $attribute_taxonomies as $attr ) { 47 $taxonomy = wc_attribute_taxonomy_name( $attr->attribute_name ); 48 $tax_obj = get_taxonomy( $taxonomy ); 75 // The stored-slug name — what products store. Equal to 76 // wc_attribute_taxonomy_name() whenever the row is not unsafe. 77 $taxonomy = 'pa_' . $attr->attribute_name; 78 if ( ! taxonomy_exists( $taxonomy ) || isset( $unsafe[ $taxonomy ] ) ) { 79 continue; 80 } 81 $tax_obj = get_taxonomy( $taxonomy ); 49 82 50 83 $result[] = [ … … 141 174 142 175 /** 143 * Clear the request-level schema cache (for testing). 176 * The key WooCommerce uses for a taxonomy attribute inside a product's 177 * attribute map. 178 * 179 * `WC_Product::set_attributes()` keys a parent's `WC_Product_Attribute` objects 180 * by `sanitize_title( $attribute->get_name() )`, and 181 * `wc_get_product_variation_attributes()` derives a variation's 182 * `taxonomy => slug` key from that same parent key. For an ASCII name 183 * (`pa_color`) this is the identity; for a non-Latin name it is the 184 * percent-encoded form (`pa_цвет` → `pa_%d1%86%d0%b2%d0%b5%d1%82`), so 185 * indexing `get_attributes()` by the raw taxonomy silently misses (#206). 186 * 187 * Every read of, or write into, a map that came from / goes to 188 * `get_attributes()` / `set_attributes()` (parent or variation) must go 189 * through this helper. Everything else — the attribute schema, `attr_*` 190 * column keys, term maps, `WC_Product_Attribute::set_name()` — stays on 191 * the RAW taxonomy name. `Product_Service::resolve_attribute_filter_slugs()` 192 * (#202) keys its result the same way. 193 * 194 * Memoized per request: callers sit in per-row × per-attribute loops 195 * (grid formatting, sibling signatures), and sanitize_title() runs the 196 * whole filter chain. This assumes that chain is stable once the request 197 * is handling products — the same assumption WC makes when it re-keys 198 * maps on read. 199 * 200 * @param string $taxonomy Raw taxonomy name, e.g. 'pa_цвет'. 201 * @return string WooCommerce's attribute-map key for it. 202 */ 203 public static function attribute_key( string $taxonomy ): string { 204 if ( ! isset( self::$attribute_keys[ $taxonomy ] ) ) { 205 self::$attribute_keys[ $taxonomy ] = sanitize_title( $taxonomy ); 206 } 207 return self::$attribute_keys[ $taxonomy ]; 208 } 209 210 /** 211 * Inverse of attribute_key(): the registered taxonomy behind a key read out of 212 * a `get_attributes()` map (parent or variation), or null when the key is not a 213 * global attribute (a custom per-product attribute, or a taxonomy that is no 214 * longer registered). 215 * 216 * Only `pa_*` keys qualify: a custom attribute named like a WordPress taxonomy 217 * ("Category" → `category`) would otherwise resolve through that taxonomy. 218 * A `pa_*` key that is itself a registered taxonomy (every ASCII name) resolves 219 * to itself. Otherwise the key is looked up in a map built from the REGISTERED 220 * pa_* taxonomy names, keyed through attribute_key(). Deliberately not 221 * urldecode( $key ): a transliterating `sanitize_title` filter makes the key 222 * `pa_czvet` for the taxonomy `pa_цвет`, which no decoding recovers. And not 223 * wc_get_attribute_taxonomy_names(): it re-runs sanitize_title() through 224 * wc_attribute_taxonomy_name(), so under that same filter it names a 225 * taxonomy (`pa_czvet`) that was never registered (#245). 226 * 227 * @param string $key Attribute-map key, e.g. 'pa_%d1%86%d0%b2%d0%b5%d1%82'. 228 * @return string|null Registered taxonomy name, e.g. 'pa_цвет'. 229 */ 230 public static function taxonomy_for_key( string $key ): ?string { 231 if ( 0 !== strncmp( $key, 'pa_', 3 ) ) { 232 return null; 233 } 234 235 if ( taxonomy_exists( $key ) ) { 236 return $key; 237 } 238 239 if ( null === self::$taxonomies_by_key ) { 240 self::$taxonomies_by_key = []; 241 foreach ( get_taxonomies() as $taxonomy ) { 242 if ( 0 === strncmp( $taxonomy, 'pa_', 3 ) ) { 243 self::$taxonomies_by_key[ self::attribute_key( $taxonomy ) ] = $taxonomy; 244 } 245 } 246 } 247 248 return self::$taxonomies_by_key[ $key ] ?? null; 249 } 250 251 /** 252 * Drop the memos built from the taxonomy registry (key map, registry hazards, 253 * schema) when a `pa_*` taxonomy is registered or unregistered. Hooked on 254 * `registered_taxonomy` / `unregistered_taxonomy` (Attribute_Handler); the 255 * memos otherwise live for the whole process, which a long-running WP-CLI or 256 * queue worker outlives. 257 * 258 * @param string $taxonomy Taxonomy name. 259 */ 260 public static function forget_taxonomy_key( string $taxonomy ): void { 261 if ( 0 === strncmp( $taxonomy, 'pa_', 3 ) ) { 262 self::$taxonomies_by_key = null; 263 self::$registry_hazards = null; 264 // The schema lists only registered, round-trippable attributes. 265 self::$schema_cache = null; 266 } 267 } 268 269 /** 270 * Labels of the attributes that make attribute writes on $product unsafe, or 271 * [] when WooCommerce can round-trip every attribute it carries. Every WCABE 272 * path that writes a product's attributes (parent attribute edits, variation 273 * attribute edits, duplication, variation creation) must refuse the product 274 * while this is non-empty. 275 * 276 * Decided from the attribute REGISTRY, not the loaded attributes alone: by the 277 * time a product is loaded, WooCommerce may already have collapsed two 278 * attributes onto one map key or dropped one (see attribute_registry_hazards()). 279 * A collision always leaves a member of the colliding group behind, and every 280 * member is unsafe, so the survivor flags the product whichever one won. A 281 * dropped attribute leaves nothing behind, so an unregistered stored taxonomy 282 * flags EVERY product. 283 * 284 * A variation is checked through its parent: its own get_attributes() is a 285 * key => slug map with no attribute names. 286 * 287 * @param \WC_Product $product Parent product, or a variation (its parent is checked). 288 * @return string[] Attribute labels, for the refusal message. 289 */ 290 public static function unsafe_attribute_labels( \WC_Product $product ): array { 291 $hazards = self::attribute_registry_hazards(); 292 if ( ! empty( $hazards['unregistered'] ) ) { 293 return $hazards['unregistered']; 294 } 295 if ( empty( $hazards['unsafe'] ) ) { 296 return []; 297 } 298 299 if ( $product instanceof \WC_Product_Variation ) { 300 $parent = wc_get_product( $product->get_parent_id() ); 301 if ( ! $parent ) { 302 return []; 303 } 304 $product = $parent; 305 } 306 307 $labels = []; 308 foreach ( $product->get_attributes() as $attribute ) { 309 if ( $attribute instanceof \WC_Product_Attribute && isset( $hazards['unsafe'][ $attribute->get_name() ] ) ) { 310 $labels[] = $hazards['unsafe'][ $attribute->get_name() ]; 311 } 312 } 313 return array_values( array_unique( $labels ) ); 314 } 315 316 /** 317 * The refusal message for a non-empty unsafe_attribute_labels() result. 318 * 319 * When the refusal is the site-wide one (an attribute's stored taxonomy is not 320 * registered), the product being saved may not carry the named attribute at 321 * all, so the message says the pause applies to every product and why. 322 * 323 * @param string[] $labels Attribute labels. 324 */ 325 public static function unsafe_attributes_message( array $labels ): string { 326 if ( ! empty( self::attribute_registry_hazards()['unregistered'] ) ) { 327 return sprintf( 328 /* translators: %s: comma-separated attribute names */ 329 __( 'Nothing was changed: attribute changes are paused for every product because WooCommerce has no registered taxonomy for the attribute(s) %s. Saving attributes now could remove them from the products that use them. This usually follows a change to how slugs are generated, such as a transliteration plugin.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 330 implode( ', ', $labels ) 331 ); 332 } 333 334 return sprintf( 335 /* translators: %s: comma-separated attribute names */ 336 __( 'Nothing was changed: WooCommerce cannot currently resolve the attribute(s) %s, and writing attributes now would convert or remove them.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 337 implode( ', ', $labels ) 338 ); 339 } 340 341 /** 342 * Global attributes WooCommerce cannot currently round-trip, from the registry. 343 * 344 * WooCommerce registers `pa_*` taxonomies at init:5 and re-derives names through 345 * sanitize_title() everywhere after that. When the sanitize chain changes in 346 * between (a transliterating filter hooked late), its own read path breaks: 347 * read_attributes() resolves ids through wc_attribute_taxonomy_id_by_name() (0, 348 * or ANOTHER attribute's id), set_attributes() collapses names that now sanitize 349 * to the same key, and an attribute whose stored taxonomy is not registered is 350 * dropped. Any attribute save then rewrites the whole set from that damaged view. 351 * 352 * Per attribute row, S = 'pa_' . attribute_name (the stored-slug taxonomy, which 353 * is what products store) and D = wc_attribute_taxonomy_name() (today's derivation): 354 * - S not registered → 'unregistered' (WC drops it from every product carrying it); 355 * - D !== S, or WC's id lookup for S is not this row's id → 'unsafe'; 356 * - two registered S sharing one attribute_key() → every member 'unsafe'. 357 * In a normal setup stored slugs are canonical, so both lists are empty. 358 * 359 * @return array{unsafe: array<string, string>, unregistered: string[]} 360 * 'unsafe' is taxonomy => label; 'unregistered' is labels. 361 */ 362 private static function attribute_registry_hazards(): array { 363 if ( null !== self::$registry_hazards ) { 364 return self::$registry_hazards; 365 } 366 367 $unsafe = []; 368 $unregistered = []; 369 $by_key = []; 370 371 foreach ( wc_get_attribute_taxonomies() as $attr ) { 372 $stored = 'pa_' . $attr->attribute_name; 373 $label = $attr->attribute_label ?: $attr->attribute_name; 374 375 if ( ! taxonomy_exists( $stored ) ) { 376 $unregistered[] = $label; 377 continue; 378 } 379 380 if ( 381 wc_attribute_taxonomy_name( $attr->attribute_name ) !== $stored 382 || wc_attribute_taxonomy_id_by_name( $stored ) !== (int) $attr->attribute_id 383 ) { 384 $unsafe[ $stored ] = $label; 385 } 386 387 $by_key[ self::attribute_key( $stored ) ][ $stored ] = $label; 388 } 389 390 foreach ( $by_key as $members ) { 391 if ( count( $members ) > 1 ) { 392 $unsafe += $members; 393 } 394 } 395 396 self::$registry_hazards = [ 397 'unsafe' => $unsafe, 398 'unregistered' => $unregistered, 399 ]; 400 return self::$registry_hazards; 401 } 402 403 /** 404 * Clear the request-level schema cache and attribute-key memos (for testing). 144 405 */ 145 406 public static function clear_cache(): void { 146 self::$schema_cache = null; 407 self::$schema_cache = null; 408 self::$attribute_keys = []; 409 self::$taxonomies_by_key = null; 410 self::$registry_hazards = null; 147 411 } 148 412 } -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Export_Service.php
r3700717 r3736631 72 72 'slug' => 'Slug', 73 73 'sku' => 'SKU', 74 // WooCommerce's own product CSV header, so the column maps on re-import (#250). 75 'global_unique_id' => 'GTIN, UPC, EAN, or ISBN', 74 76 'type' => 'Type', 75 77 'status' => 'Status', … … 256 258 $dimension_unit = $units['dimension_unit'] ?? ''; 257 259 260 // Attribute headers use the SAME label derivation as the grid column 261 // header (Attribute_Service schema + the plugin's own translatable 262 // '(attr) %s' format — the PHP mirror of assets/src/types/attribute-header.ts, 263 // keep the two literals byte-equal), so a fresh export round-trips through Paste from 264 // Excel's exact-label auto-map under every locale (#243). NOT 265 // get_taxonomy()->label: WP sets that to labels->name, which WooCommerce 266 // registers as 'Product %s' — the header no longer matched the grid. 267 $attribute_labels = []; 268 foreach ( Attribute_Service::get_attribute_schema() as $attr ) { 269 $attribute_labels[ $attr['taxonomy'] ] = $attr['label']; 270 } 271 258 272 $headers = []; 259 273 foreach ( $columns as $col ) { … … 268 282 } elseif ( strncmp( $col, 'attr_pa_', 8 ) === 0 ) { 269 283 $taxonomy = substr( $col, 5 ); // strip 'attr_' → 'pa_color' 270 $tax_obj = get_taxonomy( $taxonomy ); 271 $label = '(attr) ' . ( $tax_obj ? $tax_obj->label : $col ); 284 $label = sprintf( 285 /* translators: %s: attribute name */ 286 __( '(attr) %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 287 $attribute_labels[ $taxonomy ] ?? $taxonomy 288 ); 272 289 } elseif ( isset( $this->pro_columns[ $col ] ) ) { 273 290 $label = $this->pro_columns[ $col ]; -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Operations_Service.php
r3663491 r3736631 32 32 */ 33 33 public const MAX_COPIES_PER_PRODUCT = 50; 34 35 /** 36 * Candidates set_sku() may REJECT before assign_duplicate_sku() gives the copy a blank SKU. 37 * 38 * Normally one candidate suffices (the suffix scan starts the walk past every holder it can 39 * read); the bound only stops a uniqueness filter that rejects every candidate from walking 40 * forever. Candidates a lookup row already holds do not count — see MAX_HELD_SKU_PROBES. 41 */ 42 private const MAX_SKU_PROBES = 100; 43 44 /** 45 * Held candidates assign_duplicate_sku() walks past before giving the copy a blank SKU. 46 * 47 * Normally none: the suffix scan starts the walk past every holder it can read. A FAILED scan 48 * starts it at 1, and the walk then steps past each existing `-N` holder, one query each — 49 * slow, but the copy still gets its SKU. The ceiling only stops a `query` filter that makes 50 * every holder check read "held" from looping forever. Protected so a test subclass can 51 * shrink it. 52 */ 53 protected const MAX_HELD_SKU_PROBES = 10000; 54 55 /** 56 * Bases max_numeric_suffixes() scans per statement. Protected so a test subclass can shrink 57 * it to pin the per-chunk branch alignment. 58 */ 59 protected const SUFFIX_SCAN_CHUNK = 100; 60 61 /** 62 * Seconds acquire_sku_lock() waits for a concurrent duplicate's copy to save. A copy holds it 63 * for one variation save, so a wait this long means something is stuck: go ahead unlocked. 64 * Protected so a test subclass can shorten it. 65 */ 66 protected const SKU_LOCK_TIMEOUT = 5; 34 67 35 68 /** … … 74 107 * see the phase-2 comment. Count creations off created_ids; render rows off products. 75 108 * 109 * Follows WC core's own Duplicate (`WC_Admin_Duplicate_Product::product_duplicate()`) in what 110 * it resets (total sales, ratings, GTIN, status, slug, date) and fires the same current hooks, 111 * in core's order: `woocommerce_duplicate_product_exclude_meta`, 112 * `woocommerce_product_duplicate_before_save` (copy and each variation), 113 * `woocommerce_duplicate_product_exclude_children`, then `woocommerce_product_duplicate` once 114 * the copy is complete (core's admin action fires it; core's REST duplicate does not). The 115 * deprecated `woocommerce_duplicate_product` action (deprecated since WC 3.0, fired only by 116 * core's admin action) is not fired. It deliberately does not delegate to core, because it 117 * differs on purpose in these ways: 118 * - Copies are named "(Copy)", "(Copy 2)", … instead of always "(Copy)". 119 * - SKUs are assigned by assign_duplicate_sku(), which is stricter than core's: it respects the 120 * lookup column's width and WC's create-time lock predicate, and reports copies it had to 121 * leave without a SKU. 122 * - A variation that fails is counted and reported, where core would throw out of the copy. 123 * - A source whose attributes cannot be copied safely is refused. 124 * 76 125 * @param int[] $product_ids Source product IDs. 77 126 * @param int $count Number of copies to create per source product. … … 87 136 $failed = []; 88 137 $total_var_failures = 0; 138 // Highest SKU suffix handed out or probed per source SKU in THIS call — shared by every 139 // source, copy and variation so N copies cost one suffix scan per distinct parent SKU 140 // plus one batched scan per source's variations, not N. 141 $sku_cursor = []; 142 // Saved copies of a SKU'd source left without a SKU — counted apart, because the toast 143 // counts products only and a single total would read as more copies than it reports. 144 $sku_blanked_products = 0; 145 $sku_blanked_variations = 0; 146 // Saved copies whose post-save steps (variations, listeners) threw. 147 $unfinished = 0; 148 // SKU lock names that timed out in THIS call — see acquire_sku_lock(). 149 $lock_timeouts = []; 89 150 90 151 if ( empty( $product_ids ) ) { … … 116 177 } 117 178 179 // A copy is written from the source's LOADED attributes, which WooCommerce 180 // may already have downgraded, collapsed or dropped — refuse instead. 181 $unsafe = Attribute_Service::unsafe_attribute_labels( $source ); 182 if ( ! empty( $unsafe ) ) { 183 $failed[] = [ 184 'id' => $id, 185 'message' => Attribute_Service::unsafe_attributes_message( $unsafe ), 186 ]; 187 continue; 188 } 189 190 // 'edit': the copy must derive from the stored SKU, not a filtered view of it. 191 $source_sku = $source->get_sku( 'edit' ); 192 118 193 for ( $i = 1; $i <= $count; $i++ ) { 119 194 $suffix = ( 1 === $i ) ? ' (Copy)' : sprintf( ' (Copy %d)', $i ); … … 121 196 $duplicate->set_id( 0 ); 122 197 $duplicate->set_name( $source->get_name() . $suffix ); 123 $duplicate->set_sku( '' ); 124 $duplicate->set_status( 'draft' ); 125 $duplicate->set_date_created( null ); 126 $duplicate->set_slug( '' ); 127 $duplicate->set_total_sales( 0 ); 128 129 // Preserve media, taxonomy, attributes, and related products. 130 $duplicate->set_image_id( $source->get_image_id() ); 131 $duplicate->set_gallery_image_ids( $source->get_gallery_image_ids() ); 132 $duplicate->set_category_ids( $source->get_category_ids() ); 133 $duplicate->set_tag_ids( $source->get_tag_ids() ); 134 $duplicate->set_attributes( $source->get_attributes() ); 135 // 'edit' context: the view getters run through 136 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 137 // code can filter. Copying a filtered projection would bake that plugin's 138 // display-time view permanently into the duplicate's stored data. 139 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 140 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 141 142 if ( $source->is_type( 'variable' ) ) { 143 $duplicate->set_default_attributes( $source->get_default_attributes() ); 198 // Held from the SKU checks through the save — see acquire_sku_lock(). 199 $sku_lock = self::acquire_sku_lock( $source_sku, $lock_timeouts ); 200 try { 201 $sku_derived = $this->assign_duplicate_sku( $duplicate, $source_sku, $sku_cursor ); 202 // GTINs must be unique; a copied one is flagged "Invalid or duplicated GTIN" by WC's 203 // product editor on every later save of either product. Core's Duplicate clears it too. 204 $duplicate->set_global_unique_id( '' ); 205 $duplicate->set_status( 'draft' ); 206 $duplicate->set_date_created( null ); 207 $duplicate->set_slug( '' ); 208 $duplicate->set_total_sales( 0 ); 209 // A copy has no reviews of its own — core's Duplicate resets these too. 210 $duplicate->set_rating_counts( 0 ); 211 $duplicate->set_average_rating( 0 ); 212 $duplicate->set_review_count( 0 ); 213 214 // Preserve media, taxonomy, attributes, and related products. 215 $duplicate->set_image_id( $source->get_image_id() ); 216 $duplicate->set_gallery_image_ids( $source->get_gallery_image_ids() ); 217 $duplicate->set_category_ids( $source->get_category_ids() ); 218 $duplicate->set_tag_ids( $source->get_tag_ids() ); 219 $duplicate->set_attributes( $source->get_attributes() ); 220 // 'edit' context: the view getters run through 221 // woocommerce_product_get_upsell_ids / _cross_sell_ids, which third-party 222 // code can filter. Copying a filtered projection would bake that plugin's 223 // display-time view permanently into the duplicate's stored data. 224 $duplicate->set_upsell_ids( $source->get_upsell_ids( 'edit' ) ); 225 $duplicate->set_cross_sell_ids( $source->get_cross_sell_ids( 'edit' ) ); 226 227 if ( $source->is_type( 'variable' ) ) { 228 $duplicate->set_default_attributes( $source->get_default_attributes() ); 229 } 230 231 // Third-party code runs from here on (the exclude-meta filter, the before-save 232 // action, save listeners), so the catch below is \Throwable: an Error escaping after 233 // earlier copies were saved would fail the request, and a retry would duplicate them 234 // again. Up to save() nothing is written, so a throw there is a truthful failure. 235 try { 236 // Core's Duplicate contract, in core's order: integrations name the meta a copy 237 // must not inherit (external sync IDs and the like), then get the unsaved copy. 238 // Before WC 10.3 made brands a product prop, WC's own Brands copied them here. 239 $meta_to_exclude = self::meta_keys_excluded_from_duplicate( $source ); 240 foreach ( $meta_to_exclude as $meta_key ) { 241 $duplicate->delete_meta_data( $meta_key ); 242 } 243 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate hook, fired so listeners treat this copy like a core duplicate. Not a hook we own. 244 do_action( 'woocommerce_product_duplicate_before_save', $duplicate, $source ); 245 246 $duplicate->save(); 247 } catch ( \Throwable $e ) { 248 $failed[] = [ 249 'id' => $id, 250 'message' => sprintf( 251 /* translators: 1: copy index, 2: total copies */ 252 __( 'Failed to duplicate product (copy %1$d of %2$d).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 253 $i, 254 $count 255 ), 256 ]; 257 continue; 258 } 259 } finally { 260 self::release_sku_lock( $sku_lock ); 144 261 } 145 262 263 // SAVED from here on. A throw below must not report the copy as failed: it exists, 264 // and a "failed" copy invites a retry that duplicates it again. It stays in 265 // created_ids and is counted into the "unfinished" warning instead. 266 267 // Read back, not assumed: a before-save listener may have set a SKU of its own. 268 if ( ! $sku_derived && '' === $duplicate->get_sku( 'edit' ) ) { 269 $sku_blanked_products++; 270 } 271 146 272 try { 147 $duplicate->save(); 148 273 // Duplicate variations for variable products — unless an integration opts 274 // out through core's own filter, which core's Duplicate honours too. 275 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate filter. Not a hook we own. 276 if ( $source->is_type( 'variable' ) && ! apply_filters( 'woocommerce_duplicate_product_exclude_children', false, $source ) ) { 277 $variations = $this->duplicate_variations( $source, $duplicate, $meta_to_exclude, $sku_cursor, $lock_timeouts ); 278 $total_var_failures += $variations['failed']; 279 $sku_blanked_variations += $variations['sku_blanked']; 280 // As in core: listeners get the copy re-read with its variations in place. 281 $duplicate = wc_get_product( $duplicate->get_id() ) ?: $duplicate; 282 } 283 284 // Fired once the copy is complete — after its variations, as core's admin 285 // Duplicate does — so listeners (SEO meta copiers, translation plugins 286 // duplicating the copy) see its children. 149 287 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally firing WooCommerce's native `woocommerce_product_duplicate` action so third-party plugins listening for WC product duplications (SEO meta copiers, etc.) hook in. Not a hook we own. 150 288 do_action( 'woocommerce_product_duplicate', $duplicate, $source ); 151 152 // Duplicate variations for variable products. 153 if ( $source->is_type( 'variable' ) ) { 154 $total_var_failures += $this->duplicate_variations( $source, $duplicate ); 155 } 156 157 // Re-read from DB to get full data (variation_count, etc.). Formatting is 158 // deferred until every duplicate exists, so linked-product descriptors 159 // are resolved in one batch rather than per copy — a clone carries the 160 // source's upsells/cross-sells, so every duplicate has refs to resolve. 289 } catch ( \Throwable $e ) { 290 $unfinished++; 291 } 292 293 // Re-read from DB to get full data (variation_count, etc.) — deliberately a second 294 // read for a variable copy: it runs AFTER the listeners, so their changes reach the 295 // grid. Formatting is deferred until every duplicate exists, so linked-product 296 // descriptors are resolved in one batch rather than per copy — a clone carries the 297 // source's upsells/cross-sells, so every duplicate has refs to resolve. A re-read 298 // that throws keeps the saved object, so the copy is never dropped from 299 // created_ids; one that finds nothing (a listener deleted the copy) drops it. 300 try { 161 301 $fresh = wc_get_product( $duplicate->get_id() ); 162 if ( $fresh ) { 163 $fresh_products[] = $fresh; 164 } 165 } catch ( \Exception $e ) { 166 $failed[] = [ 167 'id' => $id, 168 'message' => sprintf( 169 /* translators: 1: copy index, 2: total copies */ 170 __( 'Failed to duplicate product (copy %1$d of %2$d).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 171 $i, 172 $count 173 ), 174 ]; 175 continue; 302 } catch ( \Throwable $e ) { 303 $fresh = $duplicate; 304 } 305 if ( $fresh ) { 306 $fresh_products[] = $fresh; 176 307 } 177 308 } … … 240 371 } 241 372 373 if ( $unfinished > 0 ) { 374 $warnings[] = sprintf( 375 /* translators: %d: number of duplicates that were saved but not finished */ 376 __( '%d duplicate(s) were created, but an error after saving (for example in another plugin) left them unfinished. Check them before duplicating again.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 377 $unfinished 378 ); 379 } 380 381 if ( $sku_blanked_products > 0 || $sku_blanked_variations > 0 ) { 382 // A copy that vanishes from a SKU-filtered grid must not do so silently (#252). 383 $warnings[] = self::sku_blank_warning( $sku_blanked_products, $sku_blanked_variations ); 384 } 385 242 386 if ( $format_failures > 0 ) { 243 387 // Appended, never replacing — a run can hit both a variation failure and a format … … 258 402 259 403 /** 404 * The warning for copies left without a SKU, naming products and variations apart: the 405 * duplicate toast reads "Duplicated N products." and counts top-level copies only, so one 406 * total over both would read as more SKU-less copies than products duplicated. 407 * 408 * @param int $products Top-level copies left without a SKU. 409 * @param int $variations Variation copies left without a SKU. 410 * @return string 411 */ 412 private static function sku_blank_warning( int $products, int $variations ): string { 413 if ( 0 === $variations ) { 414 return sprintf( 415 /* translators: %d: number of duplicated products left without a SKU */ 416 __( '%d duplicated product(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 417 $products 418 ); 419 } 420 421 if ( 0 === $products ) { 422 return sprintf( 423 /* translators: %d: number of duplicated variations left without a SKU */ 424 __( '%d duplicated variation(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 425 $variations 426 ); 427 } 428 429 return sprintf( 430 /* translators: 1: number of duplicated products left without a SKU, 2: number of duplicated variations left without a SKU */ 431 __( '%1$d duplicated product(s) and %2$d variation(s) could not get a SKU based on the original\'s and were left without one.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 432 $products, 433 $variations 434 ); 435 } 436 437 /** 260 438 * Duplicate all variations from source to new parent. 261 439 * 262 * Public so per-source duplication loops can invoke it independently of 263 * the higher-level variable-product duplicate flow. Not part of the 264 * stable extension surface; callers should treat this as internal. 265 * 266 * @param \WC_Product $source Original variable product. 267 * @param \WC_Product $new_parent Newly created duplicate. 268 * @return int Number of failed variation duplications. 269 */ 270 public function duplicate_variations( \WC_Product $source, \WC_Product $new_parent ): int { 271 $children = $source->get_children(); 272 $failed = 0; 273 274 foreach ( $children as $child_id ) { 440 * Private: its one caller is duplicate_products(). It was public for Pro's per-source 441 * duplicate loop, which has since moved into Free; the SKU cursor it shares with every copy 442 * of the call is an implementation detail, not an extension surface. 443 * 444 * @param \WC_Product $source Original variable product. 445 * @param \WC_Product $new_parent Newly created duplicate. 446 * @param array $meta_to_exclude The PARENT's excluded meta keys — see 447 * meta_keys_excluded_from_duplicate(). 448 * @param array $sku_cursor Per-call SKU suffix cursor, shared by every source and 449 * copy of the call — see assign_duplicate_sku(). 450 * @param array $lock_timeouts Per-call set of SKU lock names that timed out — see 451 * acquire_sku_lock(). 452 * @return array{failed: int, sku_blanked: int} Variation copies that failed, and saved 453 * copies of a SKU'd variation left without a SKU. 454 */ 455 private function duplicate_variations( \WC_Product $source, \WC_Product $new_parent, array $meta_to_exclude, array &$sku_cursor, array &$lock_timeouts ): array { 456 global $wpdb; 457 458 $sku_blanked = 0; 459 460 $children = []; 461 foreach ( $source->get_children() as $child_id ) { 275 462 $child = wc_get_product( $child_id ); 276 if ( ! $child || ! $child->is_type( 'variation' ) ) { 277 continue; 278 } 279 463 if ( $child && $child->is_type( 'variation' ) ) { 464 $children[] = $child; 465 } 466 } 467 $failed = 0; 468 469 // One batched suffix scan for every variation SKU not seeded yet in this call: the first 470 // copy pays it, later copies find every key seeded. 'edit' context — see below. The parent 471 // copy is already saved, so a throw here (a third-party `query` filter) must not fail it: 472 // every key left unseeded is seeded per copy by assign_duplicate_sku(), under its guard. 473 try { 474 $this->seed_sku_cursor( 475 $sku_cursor, 476 array_map( static fn( \WC_Product $child ): string => $child->get_sku( 'edit' ), $children ) 477 ); 478 } catch ( \Throwable $e ) { 479 // Fall back to the per-copy seed — see above. 480 } 481 482 // Slugs — see next_variation_slug(). One batched scan per copy, not per call: it reads 483 // the previous copy's saved variations, so no cursor has to outlive this method. A throw 484 // keeps the clones' slugs, which wp_unique_post_slug() still makes unique, only slower. 485 $slug_roots = array_map( static fn( \WC_Product $child ): string => self::variation_slug_root( $child->get_slug( 'edit' ) ), $children ); 486 $slug_cursor = []; 487 try { 488 $slug_cursor = $this->max_numeric_suffixes( $wpdb->posts, 'post_name', "post_type = 'product_variation'", $slug_roots ); 489 } catch ( \Throwable $e ) { 490 // Nothing seeded: every copy keeps its clone's slug — see above. 491 } 492 493 foreach ( $children as $i => $child ) { 280 494 $new_variation = clone $child; 281 495 $new_variation->set_id( 0 ); 282 496 $new_variation->set_parent_id( $new_parent->get_id() ); 283 $new_variation->set_sku( '' ); 284 $new_variation->set_date_created( null ); 497 $slug = self::next_variation_slug( $slug_roots[ $i ], $slug_cursor ); 498 if ( null !== $slug ) { 499 $new_variation->set_slug( $slug ); 500 } 501 // 'edit', not the default 'view': a variation's view-context SKU falls back to its 502 // PARENT's SKU, which would hand a SKU-less variation a derivative of the parent's. 503 $child_sku = $child->get_sku( 'edit' ); 504 // Held from the SKU checks through the save — see acquire_sku_lock(). 505 $sku_lock = self::acquire_sku_lock( $child_sku, $lock_timeouts ); 285 506 try { 286 $new_variation->save(); 287 } catch ( \Exception $e ) { 288 $failed++; 289 continue; 290 } 291 } 292 293 return $failed; 507 $sku_derived = $this->assign_duplicate_sku( $new_variation, $child_sku, $sku_cursor ); 508 $new_variation->set_global_unique_id( '' ); 509 $new_variation->set_date_created( null ); 510 // As in core: the PARENT's exclusion list, then the before-save action per 511 // variation. \Throwable for the same reason as duplicate_products()' per-copy 512 // catch: the parent copy is already saved, and an escaping Error would report it 513 // as failed. 514 try { 515 foreach ( $meta_to_exclude as $meta_key ) { 516 $new_variation->delete_meta_data( $meta_key ); 517 } 518 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate hook, fired so listeners treat this copy like a core duplicate. Not a hook we own. 519 do_action( 'woocommerce_product_duplicate_before_save', $new_variation, $child ); 520 521 $new_variation->save(); 522 if ( ! $sku_derived && '' === $new_variation->get_sku( 'edit' ) ) { 523 $sku_blanked++; 524 } 525 } catch ( \Throwable $e ) { 526 $failed++; 527 } 528 } finally { 529 self::release_sku_lock( $sku_lock ); 530 } 531 } 532 533 return [ 534 'failed' => $failed, 535 'sku_blanked' => $sku_blanked, 536 ]; 537 } 538 539 /** 540 * Take a MySQL named lock on a copy's source SKU for the span from assign_duplicate_sku() 541 * through the copy's save. Returns the lock name, or null when no lock is held. 542 * 543 * Two concurrent duplicates of the same product derive the same candidates: both would pass 544 * the checks for `V-RED-1` and both save it. Variation creates take no SKU lock in WC, and 545 * product create() takes one only when WC()->is_rest_api_request() holds and 546 * `wc_product_pre_lock_on_sku` does not short-circuit it — so parent and variation copies 547 * both take this one. Candidates are `<source SKU>-<n>`, and only equal source SKUs derive 548 * equal candidates (see assign_duplicate_sku()), so one lock per source SKU serialises exactly 549 * the copies that can collide: the second waits, then its checks see the first one's saved 550 * row and it walks on. Case-folded, as SKU uniqueness is case-insensitive under the column 551 * collation; accent-equal source SKUs (dirty data — WC's uniqueness check forbids them) take 552 * different locks. The lock is held while before-save and save listeners run, so a slow 553 * listener makes a concurrent duplicate wait longer. 554 * 555 * Best effort, never a failure: on a timeout, or a database without GET_LOCK() (a SQLite 556 * drop-in, some clusters), the copy goes ahead unlocked — the checks still run, only the 557 * concurrent race stays open. A name that timed out, or a GET_LOCK() that failed, is recorded 558 * in `$timed_out` and not waited on again in this call: a lock held by a stuck request would 559 * otherwise cost SKU_LOCK_TIMEOUT per copy, enough to push a "Duplicate X times" run past 560 * max_execution_time partway through. MySQL releases a dead request's locks with its 561 * connection. The name is scoped by the lookup table, so sites sharing a database server do 562 * not contend. 563 * 564 * @param string $source_sku The source's SKU in 'edit' context. 565 * @param array $timed_out Per-call set of lock names not to wait on again. 566 * @return string|null 567 */ 568 private static function acquire_sku_lock( string $source_sku, array &$timed_out ): ?string { 569 global $wpdb; 570 571 if ( '' === $source_sku ) { 572 return null; 573 } 574 575 $name = static::sku_lock_name( $source_sku ); 576 if ( isset( $timed_out[ $name ] ) ) { 577 return null; 578 } 579 580 try { 581 // Through run_chunk_query(): a failed GET_LOCK() must read as "not held", never as the 582 // previous query's stale '1'. 583 $rows = Product_Service::run_chunk_query( 584 $wpdb->prepare( 'SELECT GET_LOCK( %s, %d ) AS held', $name, static::SKU_LOCK_TIMEOUT ), 585 'duplicate SKU lock unavailable; the copy goes ahead unlocked.' 586 ); 587 } catch ( \Throwable $e ) { 588 // A third-party `query` filter threw: unlocked, as above. 589 $rows = null; 590 } 591 592 if ( isset( $rows[0]->held ) && '1' === (string) $rows[0]->held ) { 593 return $name; 594 } 595 596 $timed_out[ $name ] = true; 597 return null; 598 } 599 600 /** 601 * Release a lock taken by acquire_sku_lock(). 602 * 603 * @param string|null $name Lock name, or null when none was taken. 604 * @return void 605 */ 606 private static function release_sku_lock( ?string $name ): void { 607 global $wpdb; 608 609 if ( null === $name ) { 610 return; 611 } 612 613 try { 614 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see acquire_sku_lock(). 615 $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', $name ) ); 616 } catch ( \Throwable $e ) { 617 // Runs in a `finally`: a throw here would replace the loop's outcome. The lock goes 618 // with the connection at the end of the request. 619 } 620 } 621 622 /** 623 * Named-lock name for a source SKU: 45 characters, within MySQL's 64. Protected so a test can 624 * hold the same lock from a second connection. 625 * 626 * @param string $source_sku Source SKU. 627 * @return string 628 */ 629 protected static function sku_lock_name( string $source_sku ): string { 630 global $wpdb; 631 632 return 'wcabe_g2_sku_' . md5( $wpdb->wc_product_meta_lookup . '|' . mb_strtolower( $source_sku ) ); 633 } 634 635 /** 636 * Meta keys a duplicate of `$source` must not inherit, from the 637 * `woocommerce_duplicate_product_exclude_meta` filter of WC core's own Duplicate — 638 * integrations list their external IDs there (catalogue / POS sync IDs and the like) so a 639 * copy is not linked to the original's remote item. Applied to the copy and, as in core, to 640 * each of its variations. 641 * 642 * @param \WC_Product $source Product being duplicated. 643 * @return array 644 */ 645 private static function meta_keys_excluded_from_duplicate( \WC_Product $source ): array { 646 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core's own duplicate filter, applied so integrations' exclusions hold for this copy too. Not a hook we own. 647 $excluded = apply_filters( 648 'woocommerce_duplicate_product_exclude_meta', 649 [], 650 array_map( static fn( \WC_Meta_Data $meta ) => $meta->key, $source->get_meta_data() ) 651 ); 652 // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 653 654 return array_filter( (array) $excluded ); 655 } 656 657 /** 658 * Give a duplicate a unique SKU derived from its source: `ABC` → `ABC-1`, `ABC-2`, … 659 * 660 * Mirrors WooCommerce core's own Duplicate (WC 10.5+ `WC_Admin_Duplicate_Product:: 661 * generate_unique_sku()`, which is private/@internal), so a copy stays in a SKU-filtered 662 * grid ("contains" / "starts with") instead of vanishing on the post-duplicate refetch. 663 * A source without a SKU gives a copy without one, as in core. 664 * 665 * `$cursor` maps a source SKU to the highest suffix handed out or probed. It is seeded once 666 * per distinct SKU by seed_sku_cursor() — here lazily for a parent's SKU, in one batch per 667 * source by duplicate_variations() for its variations' — and advanced by every candidate 668 * tried, so the Nth copy of a call never rescans and never re-probes a candidate an earlier 669 * copy rejected. Each 670 * candidate, from the next suffix up, must pass three checks — normally the first one tried 671 * does: 672 * 673 * 1. It fits the lookup table's `sku` column (fits_lookup_sku_column()). A longer SKU makes 674 * wpdb reject the copy's lookup row, and under REST WC's lock INSERT truncates it — for a 675 * 100-char source, into the source's own SKU. Walking on only lengthens the suffix, so the 676 * copy gets a blank SKU instead. 677 * 2. No lookup row holds it (lookup_holds_sku()) — WC's create-time SKU lock's own predicate, 678 * trash and collation-equal holders included. The suffix scan misreads some of those (a 679 * trailing space under a PAD SPACE collation, `ß` vs `ss` under an expansion collation) 680 * and set_sku()'s uniqueness check skips trash; without this check such a holder fails the 681 * copy at create() on every attempt, and a variation (no lock) silently takes a 682 * collation-duplicate of it. 683 * 3. set_sku() accepts it. Its wc_product_has_unique_sku() check is the ONLY uniqueness check 684 * (no separate probe repeating it) and runs the uniqueness filters 685 * (`wc_product_pre_has_unique_sku` / `wc_product_has_unique_sku`), e.g. WCML's. A rejection 686 * — a filter claim, or a writer that took the candidate since check 2 — walks on to the 687 * next suffix. On rejection set_sku() also asks WC for a suggestion 688 * (wc_product_generate_unique_sku() from the candidate), which recurses without bound only 689 * under a filter that rejects every string — one that would already crash WC's own product 690 * editor on any rejected SKU. 691 * 692 * A copy is never failed over its SKU: when no candidate passes (too long, MAX_SKU_PROBES 693 * rejected by set_sku(), or MAX_HELD_SKU_PROBES held), or third-party code on the SKU path 694 * throws, it gets a blank SKU and this returns false, which duplicate_products() reports as a 695 * warning. Concurrent duplicates of the same source are serialised by the callers: both hold 696 * a named lock per source SKU from this call through the save (acquire_sku_lock()), so the 697 * second waits and then walks past the first one's saved copy. WC's own create-time SKU lock 698 * (product create(), REST requests only, filterable) is not relied on. If it still fires — a 699 * writer outside G2, or the named lock unavailable — WC deletes the half-created post and 700 * throws a plain Exception, which duplicate_products() reports as a failed copy (truthful — 701 * nothing is orphaned). Recovery is deliberately not attempted: it would re-run create() 702 * after WC's delete( true ) fired its delete hooks, and would have to recognise the lock 703 * failure by message text. Check 2 reads exactly what that lock reads, so it can only fail on 704 * a genuine race. 705 * 706 * @param \WC_Product $copy The unsaved duplicate (its ID must already be 0). 707 * @param string $source_sku The source's SKU in 'edit' context. 708 * @param array $cursor Per-call map of source SKU → highest suffix handed out or probed. 709 * @return bool False when the source has a SKU but the copy was left without one. 710 */ 711 private function assign_duplicate_sku( \WC_Product $copy, string $source_sku, array &$cursor ): bool { 712 if ( '' === $source_sku ) { 713 $copy->set_sku( '' ); 714 return true; 715 } 716 717 try { 718 if ( ! isset( $cursor[ $source_sku ] ) ) { 719 $this->seed_sku_cursor( $cursor, [ $source_sku ] ); 720 } 721 722 // Two budgets: a held candidate is a real row, so walking past it is progress; a 723 // rejection may be a filter that rejects everything. 724 $rejected = 0; 725 $held = 0; 726 while ( $rejected < self::MAX_SKU_PROBES && $held < static::MAX_HELD_SKU_PROBES ) { 727 $candidate = $source_sku . '-' . ( ++$cursor[ $source_sku ] ); 728 729 if ( ! self::fits_lookup_sku_column( $candidate ) ) { 730 break; 731 } 732 733 if ( self::lookup_holds_sku( $candidate ) ) { 734 $held++; 735 continue; 736 } 737 738 try { 739 $copy->set_sku( $candidate ); 740 return true; 741 } catch ( \WC_Data_Exception $e ) { 742 $rejected++; 743 } 744 } 745 } catch ( \Throwable $e ) { 746 // Third-party code on the SKU path threw — a uniqueness filter inside set_sku(), or a 747 // `query` / `pre_get_col_charset` filter under the checks. This runs outside the 748 // callers' per-copy try, after earlier copies were saved: an escape would 500 the 749 // request (and a retry would duplicate those copies again) or report a saved parent 750 // as failed. Give up on the SKU rather than walk on — the next candidate would run 751 // the same code. 752 } 753 754 // Blank it explicitly: the clone still carries the source's SKU. 755 $copy->set_sku( '' ); 756 return false; 757 } 758 759 /** 760 * Whether `$sku` can be stored in `wc_product_meta_lookup.sku` unchanged. 761 * 762 * Asks wpdb the same question its insert/replace helpers ask before writing (process_fields() 763 * → strip_invalid_text()): character-based on utf8mb4, and it honours a column a host has 764 * altered. A WP_Error (column metadata unreadable) counts as "does not fit". 765 * 766 * @param string $sku Candidate SKU. 767 * @return bool 768 */ 769 private static function fits_lookup_sku_column( string $sku ): bool { 770 global $wpdb; 771 772 return $wpdb->strip_invalid_text_for_column( $wpdb->wc_product_meta_lookup, 'sku', $sku ) === $sku; 773 } 774 775 /** 776 * Whether any lookup row holds `$sku` under the column collation, trash included. 777 * 778 * Direct SQL — the documented CORRECTNESS exception to the WC-CRUD rule (CLAUDE.md, PHP 779 * Backend #3): this is the predicate of WC's create-time SKU lock (`WC_Product_Data_Store_CPT:: 780 * obtain_lock_on_sku_for_concurrent_requests()`, itself raw SQL, same unslashed value), and 781 * no CRUD API reads "any lookup row". A failed query reads as "not held" — never a stale row 782 * (Product_Service::run_chunk_query()) — and set_sku()'s check and the lock still guard the 783 * save. 784 * 785 * @param string $sku Candidate SKU. 786 * @return bool 787 */ 788 private static function lookup_holds_sku( string $sku ): bool { 789 global $wpdb; 790 791 $rows = Product_Service::run_chunk_query( 792 $wpdb->prepare( 793 "SELECT 1 AS held FROM {$wpdb->wc_product_meta_lookup} WHERE sku = %s LIMIT 1", 794 $sku 795 ), 796 'duplicate SKU holder check failed; relying on set_sku() and the create-time lock.' 797 ); 798 799 return ! empty( $rows ); 800 } 801 802 /** 803 * Seed `$cursor` with the highest purely numeric `-N` suffix already used on each SKU in 804 * `$skus` (0 when none): `ABC-7` over `ABC` → 7. 805 * 806 * Direct SQL (max_numeric_suffixes()) — a documented, measured performance exception to the 807 * WC-CRUD rule (numbers below). wc_product_generate_unique_sku() alone probes one index at a 808 * time from the start, which goes quadratic across "Duplicate X times" (up to 809 * MAX_COPIES_PER_PRODUCT) and repeated duplication of the same product; WC core replaced it 810 * with a single suffix query in 10.5 for that reason. This goes one step further on both 811 * axes: MAX is computed in SQL, so a short base in an `A-00001…` catalogue returns one row 812 * instead of every match; and every unseeded SKU of `$skus` is scanned in one statement per 813 * SUFFIX_SCAN_CHUNK, so a variable product's variations cost one statement, not one per 814 * variation SKU. Empty and already-seeded SKUs are skipped. 815 * 816 * Measured (MySQL 8.0.35 over a local socket, the PHPUnit test DB, 2026-09-24; median of 5 817 * runs, SKU work only — the saves are excluded): 818 * - 50 copies of a SKU that already has 200 `-N` holders: per-index probing from 1 takes 819 * 11,276 queries / 589 ms; this scan plus the per-copy checks takes 101 queries / 4.6 ms. 820 * - A prefix with 20,000 matching lookup rows: fetching them all for a PHP MAX takes 11.5 ms 821 * and 20,000 rows of PHP memory; the SQL MAX takes 5.7 ms and returns one row. 822 * - 500 distinct SKUs: one statement each takes 500 queries / 16.1 ms; batched, 5 / 8.8 ms — 823 * and every avoided round trip costs more against a networked database. 824 * 825 * Suffix rules: see max_numeric_suffixes(). No status filter: a trashed product's SKU counts, 826 * so restoring it can never collide (WC's create-time SKU lock reads the same rows). The prefix match is case-insensitive through 827 * the column collation, like WC's own uniqueness check, and the suffix is cut by CHARACTER 828 * length, so an accent variant (`PÀ-7` over `PA`) reads as 7. 829 * 830 * The result is only where the walk starts. Holders the scan still misreads (a trailing 831 * space under a PAD SPACE collation, an expansion collation's `ß` vs `ss`) are caught by 832 * assign_duplicate_sku()'s per-candidate lock-predicate check, and a failed statement seeds 833 * 0, after which the walk steps past every existing holder — held candidates do not count 834 * toward MAX_SKU_PROBES. Either way it costs extra queries, never a wrong or missing SKU 835 * (short of MAX_HELD_SKU_PROBES holders). 836 * 837 * @param array $cursor Per-call map of source SKU → highest suffix handed out or probed. 838 * @param array $skus SKUs to seed. 839 * @return void 840 */ 841 private function seed_sku_cursor( array &$cursor, array $skus ): void { 842 global $wpdb; 843 844 $pending = array_filter( 845 $skus, 846 static fn( $sku ): bool => ! isset( $cursor[ (string) $sku ] ) 847 ); 848 849 foreach ( $this->max_numeric_suffixes( $wpdb->wc_product_meta_lookup, 'sku', '', $pending ) as $sku => $max ) { 850 $cursor[ $sku ] = $max; 851 } 852 } 853 854 /** 855 * The slug for the next copy of a variation whose slug has root `$root` (see 856 * variation_slug_root()): `root-(N+1)`, N the highest suffix handed out or already taken 857 * under that root — or null to keep the clone's slug (no root, or no scan result). 858 * 859 * A clone keeps its source's slug, which wp_unique_post_slug() then walks from `-2`, one 860 * query per taken suffix. Every earlier copy — in this call or any before — adds a step for 861 * every variation, so "Duplicate X times" went quadratic, and a variation with 3+ attributes 862 * (whose title, and so slug, is the parent's) walked past its own siblings too. WC core 863 * computes the suffix up front for the same reason (`WC_Admin_Duplicate_Product:: 864 * generate_unique_slug()`, private). The handed-out slug is normally free, so the insert 865 * pays one check; when it is not (a concurrent writer, a failed scan, a root near the 866 * 200-character `post_name` limit, which sanitize_title() truncates), 867 * wp_unique_post_slug() still walks — slower, never a duplicate slug. 868 * 869 * The scan is direct SQL on `wp_posts` (max_numeric_suffixes()) — a documented, measured 870 * performance exception to the CRUD rule: no API reads "the highest suffix in use". Scoped 871 * like the check it replaces, `post_type = 'product_variation'` and no status filter. 872 * 873 * Measured (MySQL 8.0.35 over a local socket, the PHPUnit test DB, 2026-09-24; median of 3 874 * runs of one duplicate_products() call making 50 copies of a published 20-variation 875 * product, saves included — slug checks / all queries / wall time): 876 * - 1 attribute: 26,500 / 165,557 / 8.0 s before; 1,000 (+50 scans) / 140,103 / 7.1 s after. 877 * - 3 attributes (every variation shares the parent's slug): 27,450 / 169,513 / 9.2 s 878 * before; 1,000 (+50 scans) / 143,103 / 7.7 s after. 879 * The saves dominate the wall time on a local socket; the ~25,000 avoided round trips weigh 880 * more against a networked database, and before, every EARLIER duplicate of the product 881 * lengthened each walk too. 882 * 883 * @param string $root Slug root, '' for none. 884 * @param array $cursor Per-copy map of slug root → highest suffix handed out or taken. 885 * @return string|null 886 */ 887 private static function next_variation_slug( string $root, array &$cursor ): ?string { 888 if ( '' === $root || ! isset( $cursor[ $root ] ) ) { 889 return null; 890 } 891 892 return $root . '-' . ( ++$cursor[ $root ] ); 893 } 894 895 /** 896 * A variation slug without its trailing `-N`, so copies of copies share their source's root 897 * instead of nesting (`shirt-red-3` → `shirt-red`), as in WC core. 898 * 899 * @param string $slug Slug in 'edit' context. 900 * @return string 901 */ 902 private static function variation_slug_root( string $slug ): string { 903 return (string) preg_replace( '/-[0-9]+$/', '', $slug ); 904 } 905 906 /** 907 * The highest purely numeric `-N` suffix already used on each base in `$bases`, keyed by base 908 * (0 when none; empty bases skipped): `ABC-7` over `ABC` → 7. Every row of `$table` within 909 * `$scope` counts, whatever its status. 910 * 911 * One statement per SUFFIX_SCAN_CHUNK bases — a `UNION ALL` of constant-pattern branches, 912 * each keeping its index range scan on `$column` and returning exactly one row, the MAX 913 * computed in SQL. Suffix rules: digits only — stricter than core's is_numeric(): `1e3`, 914 * `1.5` and `1-2` are not suffixes — and at most 18 of them, so the cast fits BIGINT and the 915 * PHP int. The prefix match follows the column collation and the suffix is cut by CHARACTER 916 * length. A failed statement reads as 0 for its bases. Callers document why their scan is a 917 * measured performance exception to the CRUD rule. 918 * 919 * @param string $table Table name — a $wpdb property, never input. 920 * @param string $column Column to scan — a fixed identifier, never input. 921 * @param string $scope Fixed SQL predicate ANDed into every branch, or '' — never input. 922 * @param array $bases Bases to scan. 923 * @return array<int|string, int> Base → highest suffix (numeric-string bases come back as int keys). 924 */ 925 private function max_numeric_suffixes( string $table, string $column, string $scope, array $bases ): array { 926 global $wpdb; 927 928 $pending = []; 929 foreach ( $bases as $base ) { 930 $base = (string) $base; 931 if ( '' !== $base ) { 932 $pending[ $base ] = true; 933 } 934 } 935 936 $where = '' !== $scope ? "{$scope} AND " : ''; 937 $max = []; 938 939 // array_keys() hands numeric-string bases back as ints — cast them back. 940 foreach ( array_chunk( array_map( 'strval', array_keys( $pending ) ), static::SUFFIX_SCAN_CHUNK ) as $chunk ) { 941 $branches = []; 942 $args = []; 943 foreach ( $chunk as $k => $base ) { 944 $branches[] = 'SELECT ' . (int) $k . " AS k, MAX( CAST( SUBSTRING( {$column}, CHAR_LENGTH( %s ) + 2 ) AS UNSIGNED ) ) AS m" 945 . " FROM {$table}" 946 . " WHERE {$where}{$column} LIKE %s AND SUBSTRING( {$column}, CHAR_LENGTH( %s ) + 2 ) REGEXP '^[0-9]{1,18}\$'"; 947 array_push( $args, $base, $wpdb->esc_like( $base ) . '-%', $base ); 948 } 949 950 // Through run_chunk_query(): a failed statement must read as "no rows", never as the 951 // previous query's stale rows (wpdb leaves those in place when a `query` filter 952 // empties the SQL). 953 $rows = Product_Service::run_chunk_query( 954 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- documented, measured performance exception (see the callers' docblocks). The statement is fixed SQL plus the int branch index and the caller's fixed table, column and scope; every base goes through prepare()'s three %s per branch. 955 $wpdb->prepare( implode( ' UNION ALL ', $branches ), $args ), 956 'duplicate suffix scan failed; its bases start at 0 and the walk steps past each holder.' 957 ); 958 959 $max_by_branch = []; 960 foreach ( (array) $rows as $row ) { 961 // MAX over no rows is NULL → 0. 962 $max_by_branch[ (int) $row->k ] = (int) $row->m; 963 } 964 foreach ( $chunk as $k => $base ) { 965 $max[ $base ] = $max_by_branch[ $k ] ?? 0; 966 } 967 } 968 969 return $max; 294 970 } 295 971 -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Product_Service.php
r3700717 r3736631 9 9 10 10 use WCABE_G2\Handlers\WP_Save_Hooks_Handler; 11 use WCABE_G2\Helpers\Html_Loss_Detector; 11 12 use WCABE_G2\Helpers\Product_Helper; 13 use WCABE_G2\Helpers\Term_Helper; 12 14 13 15 defined( 'ABSPATH' ) || exit; … … 17 19 /** Allowed Title-search match modes. */ 18 20 public const TITLE_SEARCH_MODES = [ 'phrase', 'all', 'any' ]; 21 22 /** Allowed Category filter modes (mirrors CategoryOperator in TS). 'in' is the default. */ 23 public const CATEGORY_OPERATORS = [ 'in', 'not_in' ]; 19 24 20 25 /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */ … … 133 138 134 139 /** 140 * A byte that survives sanitize_title_with_dashes()'s post-cap cleanup when 141 * it sits in the tail the SLUG_MAX_BYTES cap cuts off (#216). 142 * 143 * The capped encoding is a character-boundary prefix of the uncapped one, 144 * so the cap changes the slug only when that tail would have contributed to 145 * it. A tail with none of these bytes is removed in full by the post-cap 146 * stages anyway, in either context: 147 * - no `;`: no `&…;` entity removal (nor a ` `-style 'save' mapping) 148 * can match inside it or across the cut; 149 * - no `%`: no 'save'-context octet mapping or strip touches it; 150 * - everything else (`!`, `.`, `/`, whitespace, `-`, …) is stripped by 151 * `[^%a-z0-9 _-]`, or is a space / becomes a `-`, and a trailing run of 152 * those is collapsed and trimmed. 153 * Letters (either case), digits and `_` are kept. Conservative by design: 154 * a non-ASCII character that only the 'save' context strips (curly quote, 155 * soft hyphen, ZWSP…), or an entity lying wholly in the tail, still counts — 156 * exempting them would need a hand-kept copy of core's 'save' list. 157 * 158 * Mirrored by SLUG_KEPT_TAIL in validators.ts (validateSlugLength()) — 159 * keep both in step. 160 */ 161 private const SLUG_KEPT_TAIL_PATTERN = '/[A-Za-z0-9_%;]/'; 162 163 /** 135 164 * The installed core's own UTF-8 validity check. 136 165 * … … 154 183 155 184 /** 156 * Whether sanitize_title() would silently cut this slug at SLUG_MAX_BYTES. 185 * Whether sanitize_title()'s SLUG_MAX_BYTES cap would silently change this 186 * slug — store something other than what the same chain stores uncapped. 187 * 188 * Being over 200 bytes before the cap is not enough (#216): the cleanup that 189 * runs after the cap strips, collapses and trims, so `'a' × 200 . ' '` or 190 * `'a' × 150 . '!' × 60` is cut and still stored exactly as an uncapped core 191 * would store it. Comparing the CLEANED uncapped length with 200 is not the 192 * test either — `'a' × 199 . '!!b'` cleans to 200 bytes, yet the cap loses 193 * the `b`, and `'a' × 196 . '&'` splits the entity and stores `…amp`. 194 * What decides it is whether the cut-off tail holds a byte the cleanup would 195 * have kept (SLUG_KEPT_TAIL_PATTERN). 157 196 * 158 197 * Pure predicate. There is no way to ask WordPress: sanitize_title() has no … … 180 219 * so a late registration runs last among the <= 9 callbacks — immediately 181 220 * before sanitize_title_with_dashes()), calls sanitize_title() exactly as the 182 * save will, and has the observer measure the UNCAPPED encoding of the221 * save will, and has the observer test the tail the cap cuts off the 183 222 * string it receives. The last invocation wins: a re-entrant filter like 184 223 * WPML's fires the observer again with the expanded title, and that nested … … 188 227 * stages — strip_tags(), the percent-octet dance (which also rewrites 189 228 * literal ASCII `---XX---` to `%XX`), the UTF-8 validity gate, the 190 * mbstring-guarded lowercase, then utf8_uri_encode() with no length. Those 191 * have been byte-identical since WP 2.x (bar seems_utf8 → wp_is_valid_utf8, 192 * handled by is_valid_utf8_compat()); everything upstream of them — 193 * remove_accents() with its locale rules, NFC normalisation and Latin-1 194 * repair, plus every third-party callback — is core's own work, observed 195 * rather than re-derived. Keep the observer's stages in order and verbatim. 229 * mbstring-guarded lowercase, then utf8_uri_encode() with and without the 230 * cap. Those have been byte-identical since WP 2.x (bar seems_utf8 → 231 * wp_is_valid_utf8, handled by is_valid_utf8_compat()); everything upstream 232 * of them — remove_accents() with its locale rules, NFC normalisation and 233 * Latin-1 repair, plus every third-party callback — is core's own work, 234 * observed rather than re-derived. Keep the observer's stages in order and 235 * verbatim. 196 236 * 197 237 * Invalid UTF-8 mirrors core: no encoding, no cap, so never "too long" here. … … 205 245 $exceeds = false; 206 246 $observer = static function ( $title ) use ( &$exceeds ) { 207 $ length = self::uncapped_slug_bytes( (string) $title );208 $exceeds = null !== $ length && $length > self::SLUG_MAX_BYTES;247 $tail = self::slug_cap_cut_tail( (string) $title ); 248 $exceeds = null !== $tail && 1 === preg_match( self::SLUG_KEPT_TAIL_PATTERN, $tail ); 209 249 return $title; 210 250 }; … … 221 261 222 262 /** 223 * The encoded byte length sanitize_title_with_dashes() would measure this 224 * title at with its 200-byte cap removed, or null where core skips encoding 225 * (invalid UTF-8). $title is what the 'sanitize_title' filter chain hands to 226 * that callback — see slug_exceeds_wp_limit(). 263 * Sanitise description / short-description HTML the way WordPress's own 264 * editor does, and report what was removed (#248). 265 * 266 * KSES runs only for users WITHOUT `unfiltered_html`, mirroring core's 267 * kses_init_filters() and the WooCommerce product editor (multisite: super 268 * admins only; DISALLOW_UNFILTERED_HTML removes the capability). Running it 269 * for everyone silently stripped administrators' iframes and embeds on any 270 * edit. When it does run, Html_Loss_Detector lists every removed element, 271 * attribute, CSS declaration and rewritten URL so the loss is never silent. 272 * 273 * The single sanitiser behind both the save (apply_field_change()) and the 274 * dialog's pre-Apply preview (POST products/sanitize-html), so the preview 275 * shows exactly what the save will store. 276 * 277 * @param string $value Submitted HTML. 278 * @return array{value: string, losses: list<array{type: string, tag: string, attribute: string, detail: string}>} 279 */ 280 public static function sanitize_description_html( string $value ): array { 281 if ( current_user_can( 'unfiltered_html' ) ) { 282 return [ 283 'value' => $value, 284 'losses' => [], 285 ]; 286 } 287 288 $sanitized = wp_kses_post( $value ); 289 290 return [ 291 'value' => $sanitized, 292 'losses' => ( new Html_Loss_Detector() )->diff( $value, $sanitized ), 293 ]; 294 } 295 296 /** 297 * A submitted GTIN with its whitespace removed (#250). UPC / EAN codes are 298 * often printed in digit groups, and WC_Product::set_global_unique_id() 299 * strips the spaces anyway. The whitespace set (U+0009–U+000D, space, NBSP) 300 * is spelled as code points because `\s` / `\v` mean different sets in PCRE 301 * and JavaScript; normalizeGtin() in validators.ts mirrors it — keep both in 302 * step. 303 * 304 * Invalid UTF-8 makes the /u replace fail (null); the input is then returned 305 * unchanged so the format check rejects it, never read as '' (a clear). 306 * 307 * @param mixed $value Submitted value; null reads as ''. validate_changes() 308 * rejects non-scalars before the save reaches here. 309 * @return string 310 */ 311 private static function normalize_gtin( $value ): string { 312 $raw = is_scalar( $value ) ? (string) $value : ''; 313 $normalized = preg_replace( '/[\x{0009}-\x{000D}\x{0020}\x{00A0}]+/u', '', $raw ); 314 return null === $normalized ? $raw : $normalized; 315 } 316 317 /** 318 * Whether a normalised GTIN is in the form WooCommerce stores unchanged: 319 * digits and hyphens, with an X only as the final ISBN-10 check digit. 320 * set_global_unique_id() silently strips every other character (so "N/A" 321 * would wipe the stored GTIN) and throws when an X is not last; the save 322 * rejects both up front instead (#250). The pattern is WooCommerce's own 323 * format check and is mirrored by validateGtin() in validators.ts. 324 * 325 * @param string $gtin Normalised GTIN (see normalize_gtin()). 326 * @return bool 327 */ 328 private static function is_valid_gtin_format( string $gtin ): bool { 329 return 1 === preg_match( '/^[0-9\-]*[0-9Xx]?$/', $gtin ); 330 } 331 332 /** 333 * The part of this title's encoding that sanitize_title_with_dashes()'s 334 * 200-byte cap cuts off ('' when it fits), or null where core skips encoding 335 * (invalid UTF-8). utf8_uri_encode() with a length stops at a character 336 * boundary of the very string it builds without one, so the capped encoding 337 * is a prefix of the uncapped one. "Character" is the encoder's: a literal 338 * `%XX` octet the pre-cap stages preserved is three ASCII characters to it 339 * and can be split, which leaves a `%` or a hex digit in the tail — a kept 340 * byte, so SLUG_KEPT_TAIL_PATTERN rejects it. $title is what the 341 * 'sanitize_title' filter chain hands to that callback — see 342 * slug_exceeds_wp_limit(). 227 343 * 228 344 * @param string $title Title as received by the priority-10 callback. 229 * @return int|null230 */ 231 private static function uncapped_slug_bytes( string $title ): ?int{345 * @return string|null 346 */ 347 private static function slug_cap_cut_tail( string $title ): ?string { 232 348 $title = strip_tags( $title ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- mirroring sanitize_title_with_dashes() byte-for-byte. 233 349 // Preserve escaped octets, drop lone percent signs, restore octets. … … 243 359 } 244 360 245 return strlen( utf8_uri_encode( $title ) ); 361 $uncapped = utf8_uri_encode( $title ); 362 $capped = utf8_uri_encode( $title, self::SLUG_MAX_BYTES ); 363 364 return substr( $uncapped, strlen( $capped ) ); 246 365 } 247 366 … … 254 373 255 374 /** 256 * Private WP_Query marker scoping get_products()'s Product URL posts_join / posts_where 257 * callbacks to our own query. Same rationale and shape as REF_SEARCH_QUERY_VAR above — 258 * see the comment at those callbacks. 259 */ 260 private const PRODUCT_URL_QUERY_VAR = 'wcabe_product_url_filter'; 261 262 /** 263 * Private WP_Query marker scoping get_products()'s server-sort posts_clauses 264 * callback to our own query. Same rationale and shape as the two markers above — 265 * and more critical here, because the callback REPLACES the ORDER BY wholesale 266 * rather than appending a predicate. Doubles as a sentinel on the 267 * wc_get_products() args: the marker filter fires for EVERY Woo product query 268 * while registered, so it only stamps the WP_Query args when the originating 269 * query vars carry this sentinel — a nested wc_get_products() call started by a 270 * third-party listener during ours is left untouched. See build_sort_clauses(). 271 */ 272 private const SORT_QUERY_VAR = 'wcabe_sort'; 375 * Private WP_Query marker scoping EVERY posts_join / posts_where / posts_clauses 376 * callback that get_products_wc_crud() registers (stock quantity, search, description, 377 * short description, ID, the combined COGS / sale / image / manage-stock / backorders 378 * predicates, Product URL, and the server sort) to the ONE invocation that registered 379 * them. Same rationale as REF_SEARCH_QUERY_VAR above (those filters fire on every 380 * WP_Query alive while registered), and more critical for the sort callback, which 381 * REPLACES the ORDER BY wholesale rather than appending a predicate. 382 * 383 * The value is a per-invocation token, not a boolean: every callback compares it 384 * strictly against the token its own invocation minted, so a recursive service call 385 * started by a third-party listener (which mints its own token) never inherits the 386 * outer invocation's clauses. The token is derived from the nesting depth 387 * ($products_query_depth), NOT from uniqid(): a random value would land in 388 * WP_Query's posts-cache key and make every service query a guaranteed cache miss, 389 * while the depth is identical for identical requests (cache keys stay stable — the 390 * key also hashes the final SQL, so a stable token can never serve a differently 391 * filtered result) and still differs between an invocation and any service call 392 * nested inside its query. The token is set on the wc_get_products() args and 393 * reaches WP_Query through WC's unmapped-arg passthrough; a nested wc_get_products() 394 * call started by a third-party listener during ours does not carry it unless that 395 * listener copies the originating args wholesale (WC hands them to 396 * woocommerce_product_object_query_args and the data-store filter) — an accepted 397 * edge: such a clone asks for our query and gets it. See the registration block 398 * in get_products_wc_crud() for the mechanism. 399 */ 400 private const PRODUCTS_QUERY_VAR = 'wcabe_products_query'; 401 402 /** 403 * Number of get_products_wc_crud() invocations currently inside their 404 * wc_get_products() call. Incremented right before the query, decremented in its 405 * finally, and folded into the PRODUCTS_QUERY_VAR token (see there). Sibling 406 * sequential invocations share a depth — harmless, because every scoped callback 407 * is removed in the same finally before a sibling can register its own. 408 */ 409 private static int $products_query_depth = 0; 273 410 274 411 /** … … 284 421 private const QUERY_MODE_IDS = 'ids'; 285 422 private const QUERY_MODE_IDS_ALL_VARIABLE = 'ids_all_variable'; 423 424 /** 425 * Most matching _stock_status rows the stock-status pre-read (#276) may 426 * return before it is dropped for the per-row probes: it bounds the memory 427 * and the literal id lists. Only a COMMON status reaches it, and then the 428 * child EXISTS stops at its first match anyway. 429 */ 430 private const STOCK_STATUS_PREFILTER_CAP = 50000; 431 432 /** 433 * A plain decimal with at most nine integer digits, as a quoted SQL REGEXP 434 * literal — the only stored values the child numeric range tests compare; 435 * everything else is kept for PHP (sql_decimal_range_or_keep()). 436 */ 437 private const SQL_PLAIN_DECIMAL_REGEX = "'^-?[0-9]{1,9}([.][0-9]+)?$'"; 438 439 /** 440 * The plain decimals whose string truncation provably equals PHP's intval(): 441 * intval() parses through a double first, so a fraction within half an ulp of 442 * the next integer rounds up ('9.99999999999999999' → 10), while cutting the 443 * string at '.' gives 9. With at most nine integer digits the half-ulp is 444 * under 1e-7, and a fraction of at most six digits stays at least 1e-6 short 445 * of the next integer — so the two always agree. Longer fractions are kept. 446 */ 447 private const SQL_INTVAL_EXACT_DECIMAL_REGEX = "'^-?[0-9]{1,9}([.][0-9]{1,6})?$'"; 286 448 287 449 /** … … 307 469 */ 308 470 private const BUNDLE_ALLOWED_FIELDS = [ 309 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',471 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 310 472 'cogs_value', 311 473 'date_created', … … 325 487 private const ALLOWED_FIELDS = [ 326 488 'simple' => [ 327 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility',489 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 'catalog_visibility', 328 490 'cogs_value', 329 491 'date_created', … … 338 500 ], 339 501 'variable' => [ 340 'name', 'sku', ' slug', 'status', 'catalog_visibility',502 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 341 503 'cogs_value', 342 504 'date_created', … … 350 512 ], 351 513 'variation' => [ 352 'sku', ' regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status',514 'sku', 'global_unique_id', 'regular_price', 'sale_price', 'stock_quantity', 'stock_status', 'status', 353 515 'cogs_value', 354 516 'description', … … 361 523 ], 362 524 'grouped' => [ 363 'name', 'sku', ' slug', 'status', 'catalog_visibility',525 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 364 526 'date_created', 365 527 'categories', 'tags', 'description', 'short_description', … … 374 536 ], 375 537 'external' => [ 376 'name', 'sku', ' slug', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility',538 'name', 'sku', 'global_unique_id', 'slug', 'regular_price', 'sale_price', 'stock_status', 'status', 'catalog_visibility', 377 539 'cogs_value', 378 540 'date_created', … … 408 570 */ 409 571 private const DEFAULT_ALLOWED_FIELDS = [ 410 'name', 'sku', ' slug', 'status', 'catalog_visibility',572 'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility', 411 573 'date_created', 412 574 'categories', 'tags', 'description', 'short_description', … … 506 668 string $order = 'desc' 507 669 ): array { 508 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, self::QUERY_MODE_PAGE, $orderby, $order ); 509 510 $formatted = $this->format_products_page( $query_result['products'], $include_variations, $filters ); 511 512 $response = [ 513 'products' => $formatted, 514 'total' => $query_result['total'], 515 'total_pages' => $query_result['total_pages'], 516 'page' => $page, 517 'per_page' => $per_page, 518 ]; 519 520 // Only compute the "with variations" total when the client asked for 521 // variations — skipping it when include_variations=false keeps the common 522 // case free of the extra queries. Allow disabling via filter for hosts 523 // that see measurable load from the extra COUNT query. 524 if ( 525 $include_variations 526 && (bool) apply_filters( 'wcabe_g2_compute_variation_total', true, $filters ) 527 ) { 528 $variation_total = $this->get_total_with_variations( 529 (int) $query_result['total'], 530 $filters 531 ); 532 $response['total_with_variations'] = $variation_total['total']; 533 $response['total_with_variations_is_approximate'] = $variation_total['is_approximate']; 534 } 535 536 return $response; 670 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 671 self::enter_postmeta_index_scope(); 672 try { 673 $query_result = $this->get_products_wc_crud( $page, $per_page, $filters, $include_variations, self::QUERY_MODE_PAGE, $orderby, $order ); 674 675 $child_sort = $include_variations ? self::resolve_child_sort( $orderby, $order ) : null; 676 $formatted = $this->format_products_page( $query_result['products'], $include_variations, $filters, $child_sort ); 677 678 // The "across all pages, with variations" count is deliberately NOT part of 679 // the page response (#274): it re-runs the whole filtered query unpaginated, 680 // which roughly doubled the request under a variation-aware filter. The 681 // client fetches it separately (get_variation_count()), keyed on the filters 682 // only, so page and sort changes reuse it. 683 return [ 684 'products' => $formatted, 685 'total' => $query_result['total'], 686 'total_pages' => $query_result['total_pages'], 687 'page' => $page, 688 'per_page' => $per_page, 689 ]; 690 } finally { 691 self::leave_postmeta_index_scope(); 692 } 693 } 694 695 /** 696 * Count the variation rows that the grid would add across ALL matching pages 697 * with include_variations on: the publish/private variations of every matching 698 * variable parent. The client adds this to the page response's `total` for the 699 * toolbar's "across all pages" figure. 700 * 701 * Served by its own route rather than get_products() (#274): it re-runs the 702 * filtered query unpaginated, and that cost belongs to the one request that 703 * needs it, not to every page and sort change. 704 * 705 * `is_approximate` is true when variation-level filters are active: the raw 706 * COUNT does not replicate variation_matches_filters(), so the figure is an 707 * upper bound. 708 * 709 * @param array $filters Sanitized filters, as for get_products(). 710 * @return array{variation_count: int, is_approximate: bool}|null Null when the 711 * `wcabe_g2_compute_variation_total` filter disables the count. 712 */ 713 public function get_variation_count( array $filters ): ?array { 714 /** 715 * Whether to compute the "with variations" count for this filter set. Hosts 716 * that see measurable load from the extra query can switch it off. 717 * 718 * @param bool $compute Default true. 719 * @param array $filters Sanitized filters. 720 */ 721 if ( ! (bool) apply_filters( 'wcabe_g2_compute_variation_total', true, $filters ) ) { 722 return null; 723 } 724 725 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 726 self::enter_postmeta_index_scope(); 727 try { 728 $variable_parent_ids = $this->get_matching_variable_parent_ids( $filters ); 729 730 // No variable parent matches: nothing to over-count, so the zero is exact. 731 if ( empty( $variable_parent_ids ) ) { 732 return [ 733 'variation_count' => 0, 734 'is_approximate' => false, 735 ]; 736 } 737 738 return [ 739 'variation_count' => $this->count_renderable_variations( $variable_parent_ids ), 740 'is_approximate' => $this->has_variation_meta_filters( $filters ), 741 ]; 742 } finally { 743 self::leave_postmeta_index_scope(); 744 } 537 745 } 538 746 … … 550 758 * @param bool $include_variations Interleave variations under their parents. 551 759 * @param array $filters Sanitized filters (variation narrowing). 760 * @param array|null $child_sort resolve_child_sort() for the request's sort, 761 * resolved ONCE by the caller (the spec table 762 * is not memoised; the export calls this per 763 * chunk). Null keeps WooCommerce's child order. 552 764 * @return array[] Formatted rows. 553 765 */ 554 private function format_products_page( array $wc_products, bool $include_variations, array $filters ): array {766 private function format_products_page( array $wc_products, bool $include_variations, array $filters, ?array $child_sort = null ): array { 555 767 // Allow Pro to prime caches before per-product formatting. 556 768 $product_ids = array_map( fn( $p ) => $p->get_id(), $wc_products ); … … 637 849 } 638 850 851 // Order each parent's surviving children by the sort column's displayed 852 // value (#247) — after narrowing (fewer ids per chunk), before priming 853 // (hydration order is display order). 854 $children_map = $this->order_children_map( $children_map, $child_sort, $filters ); 855 639 856 $variation_ids_to_prime = []; 640 857 foreach ( $children_map as $children ) { … … 660 877 } 661 878 662 $formatted = $this->interleave_variations( $formatted, $filters, $children_map );879 $formatted = $this->interleave_variations( $formatted, $filters, $children_map, $child_sort ); 663 880 } 664 881 … … 672 889 * @return array|\WP_Error 673 890 */ 674 public function get_variations( int $product_id ) { 675 $product = wc_get_product( $product_id ); 676 677 if ( ! $product ) { 678 return new \WP_Error( 679 'product_not_found', 680 __( 'Product not found.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 681 [ 'status' => 404 ] 682 ); 683 } 684 685 // instanceof, not is_type('variable'): accepts third-party variable 686 // subclasses (variable-subscription, …) whose children are real 687 // variations (#137). 688 if ( ! $product instanceof \WC_Product_Variable ) { 689 return new \WP_Error( 690 'not_variable', 691 __( 'Product is not a variable product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 692 [ 'status' => 400 ] 693 ); 694 } 695 696 $children = $product->get_children(); 697 698 // Primes post objects, postmeta, and object term caches in ~3 batch 699 // queries. Uses both flags = true intentionally: WC's data store reads 700 // ALL postmeta per product (not specific keys), and Pro filter hooks 701 // (ACF, custom meta, custom taxonomies) also read from these caches. 702 // Do not "optimize" to targeted key-specific priming — it would break 703 // the broad cache coverage and cause individual queries to reappear. 704 if ( ! empty( $children ) ) { 705 _prime_post_caches( $children, true, true ); 706 707 // Prime attachment post caches for variation images so 708 // wp_get_attachment_image_url() inside format_variation() doesn't 709 // trigger per-image DB queries. 710 $this->prime_variation_image_caches( $children ); 711 } 712 713 $variations = []; 714 715 foreach ( $children as $child_id ) { 716 $variation = wc_get_product( $child_id ); 717 if ( $variation && $variation->is_type( 'variation' ) ) { 718 $variations[] = Product_Helper::format_variation( $variation ); 719 } 720 } 721 722 return $variations; 891 public function get_variations( int $product_id, string $orderby = 'id', string $order = 'desc' ) { 892 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 893 self::enter_postmeta_index_scope(); 894 try { 895 $product = wc_get_product( $product_id ); 896 897 if ( ! $product ) { 898 return new \WP_Error( 899 'product_not_found', 900 __( 'Product not found.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 901 [ 'status' => 404 ] 902 ); 903 } 904 905 // instanceof, not is_type('variable'): accepts third-party variable 906 // subclasses (variable-subscription, …) whose children are real 907 // variations (#137). 908 if ( ! $product instanceof \WC_Product_Variable ) { 909 return new \WP_Error( 910 'not_variable', 911 __( 'Product is not a variable product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 912 [ 'status' => 400 ] 913 ); 914 } 915 916 $children = $product->get_children(); 917 918 // Same child ordering as the interleaved page (#247): the expand-mode 919 // endpoint must show the variations in the grid's active sort. 920 $child_sort = self::resolve_child_sort( $orderby, $order ); 921 $children = $this->order_children_map( [ $product_id => $children ], $child_sort, [] )[ $product_id ] ?? $children; 922 923 // Primes post objects, postmeta, and object term caches in ~3 batch 924 // queries. Uses both flags = true intentionally: WC's data store reads 925 // ALL postmeta per product (not specific keys), and Pro filter hooks 926 // (ACF, custom meta, custom taxonomies) also read from these caches. 927 // Do not "optimize" to targeted key-specific priming — it would break 928 // the broad cache coverage and cause individual queries to reappear. 929 if ( ! empty( $children ) ) { 930 _prime_post_caches( $children, true, true ); 931 932 // Prime attachment post caches for variation images so 933 // wp_get_attachment_image_url() inside format_variation() doesn't 934 // trigger per-image DB queries. 935 $this->prime_variation_image_caches( $children ); 936 } 937 938 $variations = []; 939 940 foreach ( $children as $child_id ) { 941 $variation = wc_get_product( $child_id ); 942 if ( $variation && $variation->is_type( 'variation' ) ) { 943 $variations[] = Product_Helper::format_variation( $variation ); 944 } 945 } 946 947 return $this->order_formatted_children( [ $variations ], $child_sort, [] )[0]; 948 } finally { 949 self::leave_postmeta_index_scope(); 950 } 723 951 } 724 952 … … 782 1010 string $order = 'desc' 783 1011 ): int { 784 if ( $max_rows <= 0 ) { 785 return 0; 786 } 787 788 if ( null === $per_page ) { 789 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 790 } 791 792 $ids = $this->get_matching_product_ids( $filters, $include_variations, $orderby, $order, $max_rows ); 793 $count = 0; 794 795 foreach ( array_chunk( $ids, max( 1, $per_page ) ) as $chunk ) { 796 // Hydration lives INSIDE the try: it primes caches and runs third-party 797 // format filters (Pro's ACF reads, for one), so a throw there must still 798 // release what it allocated. 799 try { 800 // One batch prime per chunk, then PK reads from the cache — the same 801 // hydration as get_products_by_ids(); order is the chunk's order. 802 Product_Helper::prime_product_caches( $chunk ); 803 804 $wc_products = []; 805 foreach ( $chunk as $id ) { 806 $wc_product = wc_get_product( $id ); 807 if ( $wc_product ) { 808 $wc_products[] = $wc_product; 1012 // Detection scope for the postmeta index hint (see enter_postmeta_index_scope()). 1013 self::enter_postmeta_index_scope(); 1014 try { 1015 if ( $max_rows <= 0 ) { 1016 return 0; 1017 } 1018 1019 if ( null === $per_page ) { 1020 $per_page = $include_variations ? self::STREAM_PAGE_SIZE_VARIATIONS : self::STREAM_PAGE_SIZE; 1021 } 1022 1023 $ids = $this->get_matching_product_ids( $filters, $include_variations, $orderby, $order, $max_rows ); 1024 $count = 0; 1025 // Once per stream, not per chunk: resolving derives the (unmemoised) spec table. 1026 $child_sort = $include_variations ? self::resolve_child_sort( $orderby, $order ) : null; 1027 1028 foreach ( array_chunk( $ids, max( 1, $per_page ) ) as $chunk ) { 1029 // Hydration lives INSIDE the try: it primes caches and runs third-party 1030 // format filters (Pro's ACF reads, for one), so a throw there must still 1031 // release what it allocated. 1032 try { 1033 // One batch prime per chunk, then PK reads from the cache — the same 1034 // hydration as get_products_by_ids(); order is the chunk's order. 1035 Product_Helper::prime_product_caches( $chunk ); 1036 1037 $wc_products = []; 1038 foreach ( $chunk as $id ) { 1039 $wc_product = wc_get_product( $id ); 1040 if ( $wc_product ) { 1041 $wc_products[] = $wc_product; 1042 } 809 1043 } 810 } 811 812 $rows = empty( $wc_products ) 813 ? [] 814 : $this->format_products_page( $wc_products, $include_variations, $filters ); 815 816 // Interleaved variations mean a chunk can overshoot the budget 817 // mid-chunk. Slicing per chunk is equivalent to the old 818 // accumulate-then-slice: slicing a concatenation at N is slicing the 819 // last contributing chunk. 820 $remaining = $max_rows - $count; 821 if ( count( $rows ) > $remaining ) { 822 $rows = array_slice( $rows, 0, $remaining ); 823 } 824 825 $count += count( $rows ); 826 827 if ( ! empty( $rows ) ) { 828 $consumer( $rows ); 829 } 830 } finally { 831 // Drop the chunk's object graph BEFORE the cache flush — flushing the 832 // runtime cache does not release references this scope still holds. 833 unset( $wc_products, $wc_product, $rows ); 834 Product_Helper::flush_runtime_cache(); 835 836 /** 837 * Fires after each export chunk has been consumed and released. 838 * 839 * Extensions (Pro) use this to drop their own request-local caches — 840 * ACF, for instance, retains every field value it reads for the whole 841 * request, which would otherwise grow with rows × fields. 842 */ 843 do_action( 'wcabe_g2_export_after_chunk' ); 844 } 845 846 if ( $count >= $max_rows ) { 847 break; 848 } 849 } 850 851 return $count; 1044 1045 $rows = empty( $wc_products ) 1046 ? [] 1047 : $this->format_products_page( $wc_products, $include_variations, $filters, $child_sort ); 1048 1049 // Interleaved variations mean a chunk can overshoot the budget 1050 // mid-chunk. Slicing per chunk is equivalent to the old 1051 // accumulate-then-slice: slicing a concatenation at N is slicing the 1052 // last contributing chunk. 1053 $remaining = $max_rows - $count; 1054 if ( count( $rows ) > $remaining ) { 1055 $rows = array_slice( $rows, 0, $remaining ); 1056 } 1057 1058 $count += count( $rows ); 1059 1060 if ( ! empty( $rows ) ) { 1061 $consumer( $rows ); 1062 } 1063 } finally { 1064 // Drop the chunk's object graph BEFORE the cache flush — flushing the 1065 // runtime cache does not release references this scope still holds. 1066 unset( $wc_products, $wc_product, $rows ); 1067 Product_Helper::flush_runtime_cache(); 1068 1069 /** 1070 * Fires after each export chunk has been consumed and released. 1071 * 1072 * Extensions (Pro) use this to drop their own request-local caches — 1073 * ACF, for instance, retains every field value it reads for the whole 1074 * request, which would otherwise grow with rows × fields. 1075 */ 1076 do_action( 'wcabe_g2_export_after_chunk' ); 1077 } 1078 1079 if ( $count >= $max_rows ) { 1080 break; 1081 } 1082 } 1083 1084 return $count; 1085 } finally { 1086 self::leave_postmeta_index_scope(); 1087 } 852 1088 } 853 1089 … … 1059 1295 // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered — 1060 1296 // including any a third-party pre_get_posts listener spins up inside ours. Unscoped, 1061 // those queries would inherit our WHERE; and because the JOIN is added only once (the 1062 // strpos guard), a nested query could even receive the wcabe_ref_lookup WHERE WITHOUT 1063 // the JOIN and hard-fail on an unknown column. Same guard shape as 1064 // Pro_Revision_Filter_Handler::filter_post_mime_where(). 1297 // those queries would inherit our JOIN and WHERE and return a wrongly filtered 1298 // result. Same guard shape as Pro_Revision_Filter_Handler::filter_post_mime_where(). 1065 1299 $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 1066 1300 if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) { … … 1336 1570 } 1337 1571 1338 $this->apply_field_change( $product, $field, $value, $type ); 1572 // COGS is in the allowlist and validates numerically, but the write is 1573 // a no-op while WooCommerce's Cost of Goods Sold feature is off (#153). 1574 // Skip it here with a warning so the item never reports a value as saved 1575 // that was not; the other fields in the row still persist. 1576 if ( 'cogs_value' === $field && ! Product_Helper::is_cogs_enabled() ) { 1577 $warnings[] = __( 'Cost of goods is disabled on this site, so the cost value was not saved.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ); 1578 continue; 1579 } 1580 1581 $warnings = array_merge( $warnings, $this->apply_field_change( $product, $field, $value, $type ) ); 1339 1582 } 1340 1583 … … 1358 1601 // escape batch_update() entirely and 500 the request. Same rule, and the same 1359 1602 // reasoning, as the formatting phase below. 1603 // 1604 // Warning collection (#242): extension code that persisted its own fields on 1605 // the before-seam (an action, so its return is discarded) hands back item 1606 // warnings here, keyed by $save_token. Own try/catch, separate from the 1607 // after-seam below, so a throwing warnings callback can neither fail the 1608 // committed write nor skip the after-save notification. 1609 try { 1610 $filtered = apply_filters( 'wcabe_g2_batch_item_warnings', $warnings, $product, $changes, $save_token ); 1611 $warnings = is_array( $filtered ) ? array_values( array_filter( $filtered, 'is_string' ) ) : $warnings; 1612 } catch ( \Throwable $t ) { 1613 $warnings[] = sprintf( 1614 /* translators: %s: error message from the third-party callback */ 1615 __( 'The product was saved, but another plugin errored while reporting warnings: %s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1616 $t->getMessage() 1617 ); 1618 } 1619 1360 1620 try { 1361 1621 do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token ); … … 1766 2026 ]; 1767 2027 } else { 2028 $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag'; 1768 2029 foreach ( $value as $term_data ) { 1769 2030 if ( ! is_array( $term_data ) || ( ! isset( $term_data['id'] ) && ! isset( $term_data['name'] ) ) ) { … … 1774 2035 ]; 1775 2036 break; 2037 } 2038 // A NEW term (id <= 0) under a parent that no longer exists (#242): 2039 // wp_insert_term() would fail with `missing_parent` at apply time and 2040 // the term would be dropped from the list while the item reports 2041 // success. Reject the item here instead. Every offending term is 2042 // reported (a preset can carry several). Read-only: validate_item() 2043 // is a dry-run seam (Operation History pre-flight). 2044 $parent_error = self::missing_parent_error( $field, $value, $term_data, $taxonomy ); 2045 if ( null !== $parent_error ) { 2046 $errors[] = $parent_error; 1776 2047 } 1777 2048 } … … 1840 2111 __( 'SKU "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 1841 2112 $sanitized 2113 ), 2114 ]; 2115 } 2116 } elseif ( 'global_unique_id' === $field ) { 2117 // GTIN (#250). Both checks run here, not only in the setter, so the 2118 // failure comes back on the field (a red cell, not a '_general' toast) 2119 // and Pro's restore preflight, which dry-runs validate_item(), sees a 2120 // GTIN another product has taken since. Uniqueness is skipped for 2121 // empty() values ('' and '0'), exactly as set_global_unique_id() does. 2122 $gtin = self::normalize_gtin( $value ); 2123 if ( ( null !== $value && ! is_scalar( $value ) ) || ! self::is_valid_gtin_format( $gtin ) ) { 2124 $errors[] = [ 2125 'field' => 'global_unique_id', 2126 'value' => $value, 2127 'message' => sprintf( 2128 /* translators: %s: the submitted GTIN / UPC / EAN / ISBN value */ 2129 __( 'GTIN "%s" is not valid. Use digits and hyphens only; an X is allowed only as the last character.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2130 is_scalar( $value ) ? (string) $value : '' 2131 ), 2132 ]; 2133 } elseif ( ! empty( $gtin ) && ! wc_product_has_global_unique_id( $product->get_id(), $gtin ) ) { 2134 $errors[] = [ 2135 'field' => 'global_unique_id', 2136 'value' => $value, 2137 'message' => sprintf( 2138 /* translators: %s: the duplicate GTIN / UPC / EAN / ISBN value */ 2139 __( 'GTIN "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2140 $gtin 1842 2141 ), 1843 2142 ]; … … 1995 2294 * @param mixed $value 1996 2295 * @param string $type 2296 * @return string[] Item warnings raised while applying (the taxonomy branch, 2297 * #242, and HTML removed by the description sanitiser, #248); 2298 * merged into the batch item's `warnings` by batch_update(). 1997 2299 * @throws \InvalidArgumentException If validation fails. 1998 2300 */ 1999 private function apply_field_change( \WC_Product $product, string $field, $value, string $type ): void{2301 private function apply_field_change( \WC_Product $product, string $field, $value, string $type ): array { 2000 2302 switch ( $field ) { 2001 2303 case 'name': … … 2005 2307 case 'sku': 2006 2308 $product->set_sku( sanitize_text_field( (string) $value ) ); 2309 break; 2310 2311 case 'global_unique_id': 2312 // Format and uniqueness were checked in validate_changes(); a GTIN 2313 // taken between the two raises WC_Data_Exception into the item's 2314 // '_general' catch. 2315 $product->set_global_unique_id( self::normalize_gtin( $value ) ); 2007 2316 break; 2008 2317 … … 2074 2383 // rather than clobbering it from the in-memory cogs_value prop. 2075 2384 // No-op when the feature is off — set_cogs_value() would emit 2076 // wc_doing_it_wrong() otherwise. 2385 // wc_doing_it_wrong() otherwise. batch_update() already skips the 2386 // field with a per-item warning before reaching here (#153); this 2387 // guard stays as defence in depth for any other caller. 2077 2388 if ( Product_Helper::is_cogs_enabled() ) { 2078 2389 $sanitized = trim( (string) $value ); … … 2141 2452 case 'categories': 2142 2453 case 'tags': 2143 $this->apply_taxonomy_change( $product, $field, $value ); 2144 break; 2454 return $this->apply_taxonomy_change( $product, $field, $value ); 2145 2455 2146 2456 case 'description': 2147 $product->set_description( wp_kses_post( (string) $value ) );2148 break;2149 2150 2457 case 'short_description': 2151 $product->set_short_description( wp_kses_post( (string) $value ) ); 2458 $sanitized = self::sanitize_description_html( (string) $value ); 2459 if ( 'description' === $field ) { 2460 $product->set_description( $sanitized['value'] ); 2461 } else { 2462 $product->set_short_description( $sanitized['value'] ); 2463 } 2464 if ( [] !== $sanitized['losses'] ) { 2465 return [ 2466 sprintf( 2467 /* translators: 1: field label (Description / Short description), 2: semicolon-separated list of HTML changes */ 2468 __( '%1$s: some HTML was removed because your account is not allowed to save it — %2$s.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2469 'description' === $field 2470 ? __( 'Description', 'wpmelon-advanced-bulk-edit-for-woocommerce' ) 2471 : __( 'Short description', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2472 implode( '; ', Html_Loss_Detector::describe( $sanitized['losses'] ) ) 2473 ), 2474 ]; 2475 } 2152 2476 break; 2153 2477 … … 2262 2586 break; 2263 2587 } 2588 2589 return []; 2264 2590 } 2265 2591 … … 2363 2689 * Accepts an array of {id, name} objects. 2364 2690 * - id > 0: use existing term ID. 2365 * - id <= 0 + name: look up by name or create new term. 2691 * - id <= 0 + name: look up by name AND parent (hierarchical) or create 2692 * the term under that parent. 2366 2693 * 2367 2694 * @param \WC_Product $product 2368 2695 * @param string $field 'categories' or 'tags'. 2369 2696 * @param mixed $value Array of {id, name} objects. 2370 */ 2371 private function apply_taxonomy_change( \WC_Product $product, string $field, $value ): void { 2697 * @return string[] Item warnings: one per new term that could not be created 2698 * and one per existing term id that no longer resolves. 2699 */ 2700 private function apply_taxonomy_change( \WC_Product $product, string $field, $value ): array { 2372 2701 if ( ! is_array( $value ) ) { 2373 return ;2702 return []; 2374 2703 } 2375 2704 2376 2705 $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag'; 2377 2706 $term_ids = []; 2707 $warnings = []; 2378 2708 2379 2709 foreach ( $value as $term_data ) { … … 2384 2714 $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0; 2385 2715 $name = isset( $term_data['name'] ) ? sanitize_text_field( (string) $term_data['name'] ) : ''; 2716 $parent = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0; 2386 2717 2387 2718 if ( $term_id > 0 ) { … … 2389 2720 if ( $term && ! is_wp_error( $term ) ) { 2390 2721 $term_ids[] = $term_id; 2722 } else { 2723 // An existing term deleted between load and save: never drop it 2724 // silently while the item reports success (#242 follow-up). 2725 $warnings[] = self::term_missing_warning( $term_id, $name ); 2391 2726 } 2392 2727 } elseif ( $name ) { 2393 // Look up existing term by name .2394 $existing = get_term_by( 'name', $name, $taxonomy);2395 if ( $existing ) {2396 $term_ids[] = $existing ->term_id;2728 // Look up existing term by name AND parent. 2729 $existing_id = Term_Helper::find_existing_term( $name, $taxonomy, $parent ); 2730 if ( $existing_id > 0 ) { 2731 $term_ids[] = $existing_id; 2397 2732 } else { 2398 2733 // Create new term. 2399 2734 $insert_args = []; 2400 if ( ! empty( $term_data['parent'] )) {2401 $insert_args['parent'] = absint( $term_data['parent'] );2735 if ( $parent > 0 ) { 2736 $insert_args['parent'] = $parent; 2402 2737 } 2403 2738 $result = wp_insert_term( $name, $taxonomy, $insert_args ); 2404 2739 if ( ! is_wp_error( $result ) ) { 2405 2740 $term_ids[] = $result['term_id']; 2741 } else { 2742 // validate_changes() already rejects a missing parent; this is the 2743 // residual race (parent deleted between validate and insert, or any 2744 // other insert failure). Never drop the term silently (#242). 2745 $warnings[] = self::term_create_failed_warning( $name, $result ); 2406 2746 } 2407 2747 } … … 2414 2754 $product->set_tag_ids( $term_ids ); 2415 2755 } 2756 2757 return $warnings; 2758 } 2759 2760 /** 2761 * Whether a parent term id does NOT resolve in the given taxonomy. 2762 * 2763 * Read-only. A parent belonging to another taxonomy counts as missing 2764 * (get_term() returns a WP_Error for a taxonomy mismatch). 2765 * 2766 * @param int $parent Parent term id (> 0). 2767 * @param string $taxonomy Taxonomy the new term will be created in. 2768 */ 2769 public static function term_parent_missing( int $parent, string $taxonomy ): bool { 2770 $term = get_term( $parent, $taxonomy ); 2771 return ! $term || is_wp_error( $term ); 2772 } 2773 2774 /** 2775 * Validation error for a NEW term (id <= 0) whose parent no longer resolves, 2776 * or null when the term data does not describe that case. 2777 * 2778 * Shared with extension validators (Pro custom taxonomies) so both surfaces 2779 * decide and word the rejection identically. Read-only. 2780 * 2781 * @param string $field The changes key the error is reported under. 2782 * @param mixed $value The full field value (echoed back in the error). 2783 * @param array $term_data One {id, name, parent} entry of that value. 2784 * @param string $taxonomy Taxonomy the term would be created in. 2785 * @return array{field: string, value: mixed, message: string}|null 2786 */ 2787 public static function missing_parent_error( string $field, $value, array $term_data, string $taxonomy ): ?array { 2788 $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0; 2789 $parent = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0; 2790 if ( $term_id > 0 || $parent <= 0 || ! self::term_parent_missing( $parent, $taxonomy ) ) { 2791 return null; 2792 } 2793 2794 return [ 2795 'field' => $field, 2796 'value' => $value, 2797 'message' => sprintf( 2798 /* translators: %s: name of the new term */ 2799 __( 'Parent term no longer exists for "%s".', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2800 sanitize_text_field( (string) ( $term_data['name'] ?? '' ) ) 2801 ), 2802 ]; 2803 } 2804 2805 /** 2806 * Item warning for an existing term id that no longer resolves at save time. 2807 * 2808 * Shared with extension save handlers (Pro custom taxonomies). 2809 * 2810 * @param int $term_id The term id the client sent. 2811 * @param string $name Sanitized term name as the client knew it ('' if absent). 2812 */ 2813 public static function term_missing_warning( int $term_id, string $name ): string { 2814 return sprintf( 2815 /* translators: 1: term name, 2: term id */ 2816 __( 'Term "%1$s" (ID %2$d) no longer exists and was skipped.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2817 $name, 2818 $term_id 2819 ); 2820 } 2821 2822 /** 2823 * Item warning for a new term that wp_insert_term() refused to create. 2824 * 2825 * Shared with extension save handlers (Pro custom taxonomies) so the two 2826 * surfaces word the same failure the same way. 2827 * 2828 * @param string $name Sanitized term name. 2829 * @param \WP_Error $error The wp_insert_term() error. 2830 */ 2831 public static function term_create_failed_warning( string $name, \WP_Error $error ): string { 2832 return sprintf( 2833 /* translators: 1: term name, 2: WordPress error message */ 2834 __( 'Term "%1$s" could not be created and was skipped: %2$s', 'wpmelon-advanced-bulk-edit-for-woocommerce' ), 2835 $name, 2836 $error->get_error_message() 2837 ); 2416 2838 } 2417 2839 … … 2468 2890 ): array { 2469 2891 $ids_only_all_variable = ( self::QUERY_MODE_IDS_ALL_VARIABLE === $mode ); 2892 2893 // Per-invocation marker token (see PRODUCTS_QUERY_VAR): the depth this call's 2894 // query will run at. Minted before any closure below is built — they capture it 2895 // by value at construction. The depth itself is only bumped around the query. 2896 $token = 'wcabe_' . ( self::$products_query_depth + 1 ); 2897 2898 // Set by the WC-args callback and the posts_where sentinel once they see a query 2899 // carrying THIS token. Checked after the query: a lost token would otherwise make 2900 // every scoped callback bail and return the unfiltered catalogue silently. 2901 $wc_args_seen = false; 2902 $token_seen = false; 2470 2903 2471 2904 // Every non-default sort is applied by build_sort_clauses() below, which … … 2615 3048 $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value ); 2616 3049 2617 // Category filter — exact matching (include_children => false). 3050 // Category filter — exact matching (include_children => false). Only an exact 3051 // 'not_in' excludes (#147); anything else keeps the default include. 2618 3052 if ( ! empty( $filters['category'] ) ) { 2619 3053 $args['category'] = array_map( 'absint', (array) $filters['category'] ); 3054 if ( 'not_in' === ( $filters['category_operator'] ?? 'in' ) ) { 3055 $args['wcabe_category_operator'] = 'not_in'; 3056 } 2620 3057 } 2621 3058 … … 2770 3207 $has_sale_date_filter = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] ) 2771 3208 || isset( $args['wcabe_sale_end_min'] ) || isset( $args['wcabe_sale_end_max'] ); 2772 2773 // Hook for exact category matching, multi-value stock_status, price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 2774 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 2775 2776 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations ): array { 3209 // WC's own query var, read post-hook like the wcabe_* args above. 3210 $has_stock_status_filter = ! empty( $args['stock_status'] ); 3211 3212 // Hook for exact category matching, stock_status (multi-value, or handed to the combined callback with variations), price, stock qty, COGS, manage stock, backorders, sale dates, and attribute filtering. 3213 $needs_custom_query = ! empty( $filters['category'] ) || ( ! empty( $filters['stock_status'] ) && count( (array) $filters['stock_status'] ) > 1 ) || ( $include_variations && $has_stock_status_filter ) || $has_price_filter || $has_stock_qty_filter || $has_cogs_filter || $has_manage_stock_filter || $has_backorders_filter || $has_sale_date_filter || ! empty( $attribute_tax_queries ); 3214 3215 $filter_callback = function ( array $wp_query_args, array $query_vars ) use ( $include_variations, $token, &$wc_args_seen ): array { 3216 // This filter fires for every wc_get_products() call while registered; a nested 3217 // Woo query from a third-party listener passing its own `category` or a 3218 // multi-value `stock_status` must keep WC's semantics, not get ours. Only the 3219 // args carrying this invocation's token are rewritten (see PRODUCTS_QUERY_VAR). 3220 if ( ( $query_vars[ self::PRODUCTS_QUERY_VAR ] ?? null ) !== $token ) { 3221 return $wp_query_args; 3222 } 3223 $wc_args_seen = true; 3224 2777 3225 // Exact category matching — replace WC's default tax_query with include_children => false. 2778 3226 if ( ! empty( $query_vars['category'] ) ) { … … 2790 3238 } 2791 3239 2792 $ wp_query_args['tax_query'][]= [3240 $category_clause = [ 2793 3241 'taxonomy' => 'product_cat', 2794 3242 'field' => 'term_id', … … 2796 3244 'include_children' => false, 2797 3245 ]; 2798 } 2799 2800 // Multi-value stock_status — replace WC's single-value meta_query. 2801 if ( ! empty( $query_vars['stock_status'] ) && is_array( $query_vars['stock_status'] ) && count( $query_vars['stock_status'] ) > 1 ) { 2802 // Remove WC's default _stock_status meta_query. 3246 3247 // Exclude mode (#147): products in none of the terms, uncategorized ones 3248 // included. Parents only — variations follow their parent. NOT IN over no 3249 // real term would match the whole catalogue, so the controllers' [0] 3250 // "match nothing" marker for an unparseable value stays IN: a request whose 3251 // categories could not be read must never widen to everything. Accepted 3252 // edges: excluding only categories that have since been deleted (a stale 3253 // saved view) returns every product, which is true — nothing is in them; 3254 // in WPML's all-languages mode the other-language copies of an excluded 3255 // product are not excluded; PublishPress revisions (no product_cat terms) 3256 // pass. 3257 if ( 'not_in' === ( $query_vars['wcabe_category_operator'] ?? '' ) && array_filter( $category_ids ) ) { 3258 $category_clause['operator'] = 'NOT IN'; 3259 } 3260 3261 $wp_query_args['tax_query'][] = $category_clause; 3262 } 3263 3264 // Remove WC's default _stock_status meta_query (the parent's own row only). 3265 $strip_stock_status_clause = static function ( array $wp_query_args ): array { 2803 3266 if ( ! empty( $wp_query_args['meta_query'] ) ) { 2804 3267 $wp_query_args['meta_query'] = array_filter( … … 2810 3273 $wp_query_args['meta_query'] = array_values( $wp_query_args['meta_query'] ); 2811 3274 } 3275 return $wp_query_args; 3276 }; 3277 3278 if ( $include_variations && ! empty( $query_vars['stock_status'] ) ) { 3279 // With variations the combined posts_where callback below owns stock 3280 // status — `own OR a renderable child reports it` (#273) — so WC's 3281 // own-row clause must not AND in on top and veto the child branch. 3282 $wp_query_args = $strip_stock_status_clause( $wp_query_args ); 3283 } elseif ( ! empty( $query_vars['stock_status'] ) && is_array( $query_vars['stock_status'] ) && count( $query_vars['stock_status'] ) > 1 ) { 3284 // Multi-value stock_status — replace WC's single-value meta_query. 3285 $wp_query_args = $strip_stock_status_clause( $wp_query_args ); 2812 3286 2813 3287 $wp_query_args['meta_query'][] = [ … … 2986 3460 // Stock quantity range filtering via _stock meta. 2987 3461 // When include_variations is false, standard meta_query applies. 2988 // When include_variations is true, we skip the meta_query here and use a 2989 // posts_where hook instead, because variable parents often have _stock = NULL 2990 // in the DB (stock tracked per-variation) and WP meta_query can't match NULL 2991 // meta_value. The posts_where hook adds an OR for NULL values so variable 2992 // parents survive the main query; interleave_variations() filters their 2993 // variations later. 3462 // When include_variations is true it is handled by the combined posts_where 3463 // hook below instead, so a variable parent can surface through a child 3464 // variation whose (inherited) quantity matches, while a product whose own 3465 // _stock is NULL — WC's stored value for "does not manage stock" — never 3466 // matches on its own row (#259). 2994 3467 $has_stock_min = isset( $query_vars['wcabe_stock_qty_min'] ); 2995 3468 $has_stock_max = isset( $query_vars['wcabe_stock_qty_max'] ); … … 3109 3582 // variation with a matching _variation_description so the parent survives 3110 3583 // the main query and interleave_variations() can load its children. 3584 // Only publish/private variations count, mirroring the render path 3585 // (WC_Product_Variable::get_children()) and the with-variations count, so a 3586 // draft/pending/trashed child cannot surface a parent that renders empty. 3111 3587 $description_where_callback = null; 3112 3588 if ( $search_description_term ) { … … 3119 3595 // conversion must not surface its now-simple parent. 3120 3596 $is_variable = $this->sql_is_variable_product(); 3121 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()); the search term is passed through prepare() via %s.3597 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- $is_variable is this class's own hardcoded SQL literal (sql_is_variable_product()) and sql_postmeta_by_post_id() returns this class's own postmeta table reference (plus the post_id index hint only where that key exists); the search term is passed through prepare() via %s. 3122 3598 $where .= $wpdb->prepare( 3123 3599 " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN (" 3124 3600 . " SELECT p.post_parent FROM {$wpdb->posts} p" 3125 . " INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id"3601 . ' INNER JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . ' ON p.ID = pm.post_id' 3126 3602 . " WHERE p.post_type = 'product_variation'" 3603 . " AND p.post_status IN ('publish','private')" 3127 3604 . " AND pm.meta_key = '_variation_description'" 3128 3605 . " AND pm.meta_value LIKE %s" … … 3131 3608 $desc_like 3132 3609 ); 3133 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 3610 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared 3134 3611 } else { 3135 3612 $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like ); … … 3195 3672 } 3196 3673 3197 // Stock qty filtering via posts_where when include_variations is true.3198 // WP meta_query can't match NULL meta_value, so we use raw SQL that allows3199 // products with NULL _stock through (variable parents track stock per-variation).3200 // interleave_variations() handles filtering variations; variation_matches_filters()3201 // handles filtering simple/non-variable products post-query if needed.3202 $stock_qty_join_callback = null;3203 $stock_qty_where_callback = null;3204 3205 if ( $include_variations && $has_stock_qty_filter ) {3206 global $wpdb;3207 3208 $stock_qty_min = isset( $filters['stock_qty_min'] ) && '' !== $filters['stock_qty_min']3209 ? (float) $filters['stock_qty_min'] : null;3210 $stock_qty_max = isset( $filters['stock_qty_max'] ) && '' !== $filters['stock_qty_max']3211 ? (float) $filters['stock_qty_max'] : null;3212 3213 $stock_qty_join_callback = function ( string $join ) use ( $wpdb ): string {3214 if ( strpos( $join, 'wcabe_stock_meta' ) === false ) {3215 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_stock_meta ON ( {$wpdb->posts}.ID = wcabe_stock_meta.post_id AND wcabe_stock_meta.meta_key = '_stock' ) ";3216 }3217 return $join;3218 };3219 3220 $stock_qty_where_callback = function ( string $where ) use ( $wpdb, $stock_qty_min, $stock_qty_max ): string {3221 if ( null !== $stock_qty_min && null !== $stock_qty_max ) {3222 $where .= $wpdb->prepare(3223 " AND ( ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) BETWEEN %f AND %f ) OR wcabe_stock_meta.meta_value IS NULL ) ",3224 $stock_qty_min,3225 $stock_qty_max3226 );3227 } elseif ( null !== $stock_qty_min ) {3228 $where .= $wpdb->prepare(3229 " AND ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) >= %f OR wcabe_stock_meta.meta_value IS NULL ) ",3230 $stock_qty_min3231 );3232 } else {3233 $where .= $wpdb->prepare(3234 " AND ( CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) ) <= %f OR wcabe_stock_meta.meta_value IS NULL ) ",3235 $stock_qty_max3236 );3237 }3238 return $where;3239 };3240 }3241 3242 3674 // Product URL filtering (external/affiliate link, _product_url meta). 3243 // Raw SQL rather than meta_query for the same reason as stock qty above:3675 // Raw SQL rather than meta_query because 3244 3676 // WP's meta_query cannot express "meta row missing", which the is_empty 3245 3677 // operator needs. Every column is alias-qualified — an unqualified 3246 3678 // post_id/meta_key/meta_value becomes ambiguous the moment another meta 3247 3679 // join (e.g. stock qty) is active in the same query. 3248 $product_url_join_callback = null; 3249 $product_url_where_callback = null; 3250 $product_url_marker_callback = null; 3680 $product_url_join_callback = null; 3681 $product_url_where_callback = null; 3251 3682 3252 3683 if ( $has_product_url_filter ) { 3253 3684 global $wpdb; 3254 3685 3255 // posts_join / posts_where fire on EVERY WP_Query for as long as they are 3256 // registered — including any a third-party pre_get_posts listener spins up 3257 // inside ours. Unscoped, those queries would inherit our WHERE; and because 3258 // the JOIN is added only once (the strpos guard), a nested query could even 3259 // receive the WHERE WITHOUT the JOIN and hard-fail on an unknown column. 3260 // So both callbacks below bail unless the query carries our private marker. 3261 // The marker filter itself also fires for every wc_get_products() call while 3262 // registered, so it only stamps queries whose ORIGINATING query vars carry 3263 // our sentinel (set on $args below; WC_Object_Query keeps unknown args via 3264 // wp_parse_args and hands them to this filter as its second parameter) — 3265 // a nested Woo product query never gets stamped. Same guard shape as the 3266 // SORT_QUERY_VAR marker. 3267 $args[ self::PRODUCT_URL_QUERY_VAR ] = true; 3268 3269 $product_url_marker_callback = static function ( array $wp_query_args, array $query_vars ): array { 3270 if ( ! empty( $query_vars[ self::PRODUCT_URL_QUERY_VAR ] ) ) { 3271 $wp_query_args[ self::PRODUCT_URL_QUERY_VAR ] = true; 3272 } 3273 return $wp_query_args; 3274 }; 3275 3276 $product_url_join_callback = function ( string $join, \WP_Query $query ) use ( $wpdb ): string { 3277 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 3278 return $join; 3279 } 3686 $product_url_join_callback = function ( string $join ) use ( $wpdb ): string { 3280 3687 if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) { 3281 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_metaON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) ";3688 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_product_url_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_product_url_meta.post_id AND wcabe_product_url_meta.meta_key = '_product_url' ) "; 3282 3689 } 3283 3690 return $join; … … 3286 3693 $is_external_sql = $this->sql_is_external_product(); 3287 3694 3288 $product_url_where_callback = function ( string $where, \WP_Query $query ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 3289 if ( ! $query->get( self::PRODUCT_URL_QUERY_VAR ) ) { 3290 return $where; 3291 } 3695 $product_url_where_callback = function ( string $where ) use ( $wpdb, $product_url_operator, $product_url_value, $is_external_sql ): string { 3292 3696 3293 3697 $col = 'wcabe_product_url_meta.meta_value'; … … 3350 3754 3351 3755 // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date, 3352 // manage stock, backorders ──3756 // manage stock, backorders, stock quantity, stock status ── 3353 3757 // 3354 // ONE callback pair covers all sixdeliberately. Each of them can surface a3758 // ONE callback pair covers all eight deliberately. Each of them can surface a 3355 3759 // variable parent either on its OWN value or through a child variation, and 3356 3760 // emitting them as separate `( own_i OR child_i )` clauses would let *different* … … 3370 3774 // single-filter behaviour is unchanged. 3371 3775 // 3372 // SCOPE: these six filters only. Price, stock status, stock quantity, SKU 3373 // search and description search surface parents through other mechanisms 3374 // (meta_query, the OR-NULL stock posts_where above, IN subqueries below) and 3375 // are NOT folded in — a filter from this group combined with one of those can 3376 // still produce an empty parent row. Known and accepted; folding the rest in 3377 // is tracked separately. 3776 // SCOPE: these eight filters only. Price, SKU search and description search 3777 // surface parents through other mechanisms (meta_query, IN subqueries below) 3778 // and are NOT folded in — a filter from this group combined with one of those 3779 // can still produce an empty parent row. Known and accepted. 3780 // Stock quantity joined the group in #259: its old standalone callback let 3781 // every product with a NULL own _stock through (WC stores NULL for "does not 3782 // manage stock"), so non-variable products matched any range and non-managing 3783 // variable parents surfaced with no matching child. 3784 // Stock status joined in #273: on WC's own meta_query a variable parent could 3785 // match only on its own _stock_status, which WC syncs from the children 3786 // (instock while ANY child is), so "out of stock" never reached the 3787 // out-of-stock variations of a family that still had one in stock. 3378 3788 // 3379 3789 // The child predicates themselves live in build_variation_child_predicates() 3380 3790 // ('surface' mode here), shared with the candidate query that pre-narrows 3381 // which children get hydrated (narrow_children_by_sql(), #218). That query 3382 // additionally narrows by stock status and stock quantity — as a pre-filter 3383 // only; it never changes which parents this callback surfaces. 3791 // which children get hydrated (narrow_children_by_sql(), #218). 3384 3792 // 3385 3793 // The child status list matches WC get_children() (publish/private only) so a … … 3389 3797 // its now-simple parent (interleaving only narrows children of variable 3390 3798 // parents, so such a false positive is uncorrectable downstream). COGS, sale 3391 // dates, manage stock and backorders participate only when include_variations3392 // is on; with it off they stay on their meta_query paths and never reach this3393 // callback, so with variations off only menu order and image date are emitted3394 // here.3799 // dates, manage stock, backorders, stock quantity and stock status participate 3800 // only when include_variations is on; with it off they stay on their 3801 // meta_query paths and never reach this callback, so with variations off only 3802 // menu order and image date are emitted here. 3395 3803 // 3396 3804 // Manage stock / backorders child predicates use the value WooCommerce REPORTS … … 3410 3818 // The OWN predicates never coalesce: a product with no row must behave the same 3411 3819 // as on the variation-off meta_query path, which requires a real row (#207). 3820 // The stock quantity own predicate is that meta_query's shape (a 3821 // DECIMAL(10,2) cast of the own _stock, NULL failing), so non-variable rows are 3822 // identical with the toggle on or off (for bounds of up to six decimals, the 3823 // %f rendering the other own predicates share); its child predicate inherits like 3824 // backorders and is never tighter than PHP (see the builder). 3825 // The stock status own predicate is WC's meta_query shape too (any own 3826 // _stock_status row in the list, collation-bound, a missing row failing), so 3827 // non-variable rows are again identical with the toggle on or off. It is an 3828 // EXISTS, not a joined alias: the meta_query it replaces brought WP_Query's 3829 // GROUP BY with it, and a plain join would duplicate a product that carries two 3830 // _stock_status rows. Its child predicate is byte-exact (see the builder). 3412 3831 $combined_join_callback = null; 3413 3832 $combined_where_callback = null; … … 3435 3854 $use_ms = $include_variations && $has_manage_stock_filter; 3436 3855 $use_bk = $include_variations && $has_backorders_filter; 3856 $use_qty = $include_variations && $has_stock_qty_filter; 3857 $use_ss = $include_variations && $has_stock_status_filter; 3437 3858 3438 3859 $manage_stock_values = $use_ms ? array_values( array_map( 'strval', (array) $args['wcabe_manage_stock'] ) ) : []; 3439 3860 $backorders_values = $use_bk ? array_values( array_map( 'strval', (array) $args['wcabe_backorders'] ) ) : []; 3440 3441 if ( $use_cogs || $use_sale || $use_menu || $use_image || $use_ms || $use_bk ) { 3861 // (array): WC's query var also takes a single string, and a hook may set one. 3862 $stock_status_values = $use_ss ? array_values( array_map( 'strval', (array) $args['stock_status'] ) ) : []; 3863 3864 // Keyed on $args like the value lists above (see the surface spec below). 3865 $stock_qty_min = $use_qty && isset( $args['wcabe_stock_qty_min'] ) ? (float) $args['wcabe_stock_qty_min'] : null; 3866 $stock_qty_max = $use_qty && isset( $args['wcabe_stock_qty_max'] ) ? (float) $args['wcabe_stock_qty_max'] : null; 3867 3868 if ( $use_cogs || $use_sale || $use_menu || $use_image || $use_ms || $use_bk || $use_qty || $use_ss ) { 3442 3869 global $wpdb; 3443 3870 … … 3467 3894 ); 3468 3895 3469 // Only COGS, sale dates, image date, manage stock and backorders need3470 // parent-side joins; menu_order reads a native wp_posts column.3471 if ( $use_cogs || $use_sale || $use_image || $use_ms || $use_bk ) {3472 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $use_ms, $use_bk, $ sale_predicates ): string {3896 // Only COGS, sale dates, image date, manage stock, backorders and stock 3897 // quantity need parent-side joins; menu_order reads a native wp_posts column. 3898 if ( $use_cogs || $use_sale || $use_image || $use_ms || $use_bk || $use_qty ) { 3899 $combined_join_callback = function ( string $join ) use ( $wpdb, $use_cogs, $use_image, $use_ms, $use_bk, $use_qty, $sale_predicates ): string { 3473 3900 if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) { 3474 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_metaON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) ";3901 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_cogs_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_cogs_meta.post_id AND wcabe_cogs_meta.meta_key = '_cogs_total_value' ) "; 3475 3902 } 3476 3903 3477 // The parent's manage flag is needed for BOTH filters: backorders3478 // inherit from the parent only while the parent manages stock.3479 if ( ( $use_ms || $use_bk ) && strpos( $join, 'wcabe_ms_meta' ) === false ) {3480 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_ms_meta ON ( {$wpdb->posts}.ID = wcabe_ms_meta.post_id AND wcabe_ms_meta.meta_key = '_manage_stock' ) ";3904 // Own _stock for the own predicate, and the parent's _stock that a 3905 // non-managing child inherits in the child predicate. 3906 if ( $use_qty && strpos( $join, 'wcabe_stock_meta' ) === false ) { 3907 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_stock_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_stock_meta.post_id AND wcabe_stock_meta.meta_key = '_stock' ) "; 3481 3908 } 3482 3909 3910 // The parent's manage flag is needed for all three stock filters: 3911 // backorders and stock quantity inherit from the parent only while 3912 // the parent manages stock. 3913 if ( ( $use_ms || $use_bk || $use_qty ) && strpos( $join, 'wcabe_ms_meta' ) === false ) { 3914 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_ms_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_ms_meta.post_id AND wcabe_ms_meta.meta_key = '_manage_stock' ) "; 3915 } 3916 3483 3917 if ( $use_bk && strpos( $join, 'wcabe_bk_meta' ) === false ) { 3484 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_bk_metaON ( {$wpdb->posts}.ID = wcabe_bk_meta.post_id AND wcabe_bk_meta.meta_key = '_backorders' ) ";3918 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_bk_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_bk_meta.post_id AND wcabe_bk_meta.meta_key = '_backorders' ) "; 3485 3919 } 3486 3920 3487 3921 foreach ( $sale_predicates as $range ) { 3488 3922 if ( strpos( $join, $range['parent_alias'] ) === false ) { 3489 $join .= " LEFT JOIN {$wpdb->postmeta} AS {$range['parent_alias']}ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) ";3923 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( $range['parent_alias'] ) . " ON ( {$wpdb->posts}.ID = {$range['parent_alias']}.post_id AND {$range['parent_alias']}.meta_key = '{$range['meta_key']}' ) "; 3490 3924 } 3491 3925 } … … 3499 3933 // so a _thumbnail_id pointing at a non-attachment is excluded here 3500 3934 // and renders blank there. 3501 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_metaON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) ";3935 $join .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_img_meta' ) . " ON ( {$wpdb->posts}.ID = wcabe_img_meta.post_id AND wcabe_img_meta.meta_key = '_thumbnail_id' ) "; 3502 3936 $join .= " LEFT JOIN {$wpdb->posts} AS wcabe_img ON ( wcabe_img.ID = CAST( wcabe_img_meta.meta_value AS UNSIGNED ) AND wcabe_img.post_type = 'attachment' ) "; 3503 3937 } … … 3512 3946 // spec keyed on $args, not $filters — a wcabe_g2_get_products_args hook 3513 3947 // may legitimately change which PARENTS surface (#218 keeps that intact). 3514 // stock_status / stock_qty are deliberately absent: they surface parents3515 // through their own mechanisms (see the SCOPE note) and only the3516 // candidate query (narrow_children_by_sql()) narrows children by them.3517 3948 // The builder reads every axis through `! empty( $spec[...] )`, so an 3518 3949 // absent key and a null one are the same thing. 3950 // The stock-status matches (#276), read once from the _stock_status rows 3951 // instead of one probe per product and per variation: the products whose 3952 // own row matches, and the parents of the variations that match. Null: 3953 // no pre-read, both branches keep their EXISTS. 3954 $ss_ids = $use_ss ? $this->stock_status_surface_ids( $stock_status_values, $filters ) : null; 3955 3519 3956 $surface_spec = [ 3520 3957 'cogs' => $use_cogs ? [ 'min' => $cogs_min, 'max' => $cogs_max ] : null, … … 3524 3961 'manage_stock' => $use_ms ? $manage_stock_values : null, 3525 3962 'backorders' => $use_bk ? $backorders_values : null, 3963 'stock_qty' => $use_qty ? [ 'min' => $stock_qty_min, 'max' => $stock_qty_max ] : null, 3964 'stock_status' => $use_ss ? $stock_status_values : null, 3526 3965 // The EXISTS is correlated to the outer parent row, whose 3527 // _manage_stock / _backorders rows the join callback above already 3528 // attaches (wcabe_ms_meta whenever $use_ms || $use_bk, wcabe_bk_meta 3529 // whenever $use_bk — exactly when the builder needs each). Reuse them 3530 // instead of re-joining the same rows once per child row scanned. 3531 'parent_ms_col' => 'wcabe_ms_meta.meta_value', 3532 'parent_bk_col' => 'wcabe_bk_meta.meta_value', 3966 // _manage_stock / _backorders / _stock rows the join callback above 3967 // already attaches (wcabe_ms_meta whenever $use_ms || $use_bk || 3968 // $use_qty, wcabe_bk_meta whenever $use_bk, wcabe_stock_meta whenever 3969 // $use_qty — exactly when the builder needs each). Reuse them instead 3970 // of re-joining the same rows once per child row scanned. 3971 'parent_ms_col' => 'wcabe_ms_meta.meta_value', 3972 'parent_bk_col' => 'wcabe_bk_meta.meta_value', 3973 'parent_stock_col' => 'wcabe_stock_meta.meta_value', 3533 3974 ]; 3534 3975 … … 3542 3983 $use_ms, 3543 3984 $use_bk, 3985 $use_qty, 3986 $use_ss, 3544 3987 $sale_predicates, 3545 3988 $cogs_min, … … 3551 3994 $manage_stock_values, 3552 3995 $backorders_values, 3996 $stock_qty_min, 3997 $stock_qty_max, 3998 $stock_status_values, 3999 $ss_ids, 3553 4000 $surface_spec 3554 4001 ): string { … … 3595 4042 } 3596 4043 4044 if ( $use_qty ) { 4045 // The variations-off meta_query's shape: a NULL or missing own 4046 // _stock fails, so a product that does not manage stock never 4047 // matches on its own row (#259). 4048 $own_parts[] = self::sql_bound( $wpdb, 'CAST( wcabe_stock_meta.meta_value AS DECIMAL(10,2) )', $stock_qty_min, $stock_qty_max, '%f' ); 4049 } 4050 4051 if ( $use_ss ) { 4052 // WC's meta_query, which the filter callback stripped (see the 4053 // header note): any own _stock_status row in the list. With the 4054 // #276 pre-read, the ids that have such a row — the same test. 4055 $own_parts[] = null !== $ss_ids 4056 ? self::sql_id_in( "{$wpdb->posts}.ID", $ss_ids['own'] ) 4057 : 'EXISTS ( SELECT 1 FROM ' . self::sql_postmeta_by_post_id( 'wcabe_ss_meta' ) 4058 . " WHERE wcabe_ss_meta.post_id = {$wpdb->posts}.ID AND wcabe_ss_meta.meta_key = '_stock_status' AND " 4059 . self::sql_in( $wpdb, 'wcabe_ss_meta.meta_value', $stock_status_values ) . ' )'; 4060 } 4061 3597 4062 $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )'; 3598 4063 … … 3601 4066 $fragment = $this->build_variation_child_predicates( $surface_spec, 'surface' ); 3602 4067 if ( ! empty( $fragment['parts'] ) ) { 3603 $child = "( {$is_variable} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$fragment['joins']}" 4068 // #276: only the parents of a variation with a selected status 4069 // reach the EXISTS, which stays the gate for every axis. 4070 $prefilter = self::sql_id_restriction( "{$wpdb->posts}.ID", null !== $ss_ids ? $ss_ids['parents'] : null ); 4071 $child = "( {$is_variable}{$prefilter} AND EXISTS ( SELECT 1 FROM {$wpdb->posts} c{$fragment['joins']}" 3604 4072 . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'" 3605 4073 . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $fragment['parts'] ) . ' ) )'; … … 3626 4094 // (order_by_price_{asc,desc}_post_clauses in includes/class-wc-query.php). 3627 4095 $sort_clauses_callback = null; 3628 $sort_marker_callback = null;3629 4096 3630 4097 $sort_spec = ( 'id' !== $orderby && ! $ids_only_all_variable ) … … 3633 4100 3634 4101 if ( null !== $sort_spec ) { 3635 // posts_clauses fires on EVERY WP_Query for as long as it is registered —3636 // including any a third-party pre_get_posts listener spins up inside ours —3637 // and this callback REPLACES the ORDER BY wholesale, so unscoped it would3638 // silently reorder (and join a foreign table into) a nested query. Both3639 // callbacks below bail unless the query carries our private marker. The3640 // marker filter itself also fires for every wc_get_products() call while3641 // registered, so it only stamps queries whose ORIGINATING query vars carry3642 // our sentinel (set on $args here; WC_Object_Query keeps unknown args via3643 // wp_parse_args and hands them to this filter as its second parameter) —3644 // a nested Woo product query never gets stamped. Same guard shape as the3645 // PRODUCT_URL_QUERY_VAR marker (REF_SEARCH_QUERY_VAR is safe differently:3646 // search_products() stamps its own WP_Query args directly, no filter).3647 $args[ self::SORT_QUERY_VAR ] = true;3648 3649 $sort_marker_callback = static function ( array $wp_query_args, array $query_vars ): array {3650 if ( ! empty( $query_vars[ self::SORT_QUERY_VAR ] ) ) {3651 $wp_query_args[ self::SORT_QUERY_VAR ] = true;3652 }3653 return $wp_query_args;3654 };3655 3656 4102 $sort_clauses_callback = self::build_sort_clauses( $sort_spec, 'ASC' === $wc_order ); 4103 } 4104 4105 // posts_join / posts_where / posts_clauses fire on EVERY WP_Query for as long 4106 // as they are registered — including any a third-party pre_get_posts listener 4107 // (multilingual, membership, …) spins up inside ours. Unscoped, those queries 4108 // would inherit our JOINs and WHERE and return a wrongly filtered result — and 4109 // the sort callback REPLACES the ORDER BY wholesale, so it would silently 4110 // reorder them too. Every callback registered below therefore bails unless the 4111 // query carries this invocation's token under PRODUCTS_QUERY_VAR. 4112 // 4113 // How the token reaches WP_Query: it is set on the wc_get_products() args, and 4114 // WC's data store passes every unmapped arg straight through to the WP_Query 4115 // args (WC_Data_Store_WP::get_wp_query_args(), verified on WC 11.1) — that 4116 // passthrough is the load-bearing step, so this `$args` stamp must stay. The 4117 // marker filter below is defence in depth only: it re-stamps the token from the 4118 // ORIGINATING query vars in case a WC version or a lower-priority listener 4119 // strips unknown keys. A nested wc_get_products() started by a third-party 4120 // listener is not stamped because its own args do not carry the key (unless 4121 // the listener clones the originating args wholesale — accepted, see 4122 // PRODUCTS_QUERY_VAR), and a recursive service call mints its own token. (REF_SEARCH_QUERY_VAR is safe 4123 // differently: search_products() stamps its own WP_Query args directly.) 4124 // 4125 // The one thing the guards cannot see is the token NOT arriving at all (a 4126 // listener AFTER the re-stamp that rebuilds the args from a whitelist, or a 4127 // pre_get_posts listener that reassigns query_vars): every callback would bail 4128 // and the grid would show the whole catalogue with its filter chips still on. 4129 // So the sentinel below records whether any WP_Query carried this token, and 4130 // the check after the query turns a lost token into an exception rather than 4131 // a silently unfiltered result set. 4132 $args[ self::PRODUCTS_QUERY_VAR ] = $token; 4133 4134 $products_marker_callback = static function ( array $wp_query_args, array $query_vars ): array { 4135 if ( ! empty( $query_vars[ self::PRODUCTS_QUERY_VAR ] ) ) { 4136 $wp_query_args[ self::PRODUCTS_QUERY_VAR ] = $query_vars[ self::PRODUCTS_QUERY_VAR ]; 4137 } 4138 return $wp_query_args; 4139 }; 4140 4141 $token_sentinel = static function ( string $where, \WP_Query $query ) use ( $token, &$token_seen ): string { 4142 if ( $query->get( self::PRODUCTS_QUERY_VAR ) === $token ) { 4143 $token_seen = true; 4144 } 4145 return $where; 4146 }; 4147 4148 // Every posts_* callback this invocation registers, in registration order, 4149 // wrapped by scoped_to() so it fires for this invocation's query only. The 4150 // clause order is preserved from before the wrapping: joins in the order the 4151 // WHEREs reference their aliases, WHEREs as AND-appended predicates. 4152 $scoped_callbacks = []; 4153 foreach ( [ 4154 [ 'posts_join', $product_url_join_callback ], 4155 [ 'posts_where', $product_url_where_callback ], 4156 [ 'posts_join', $combined_join_callback ], 4157 [ 'posts_where', $combined_where_callback ], 4158 [ 'posts_join', $search_join_callback ], 4159 [ 'posts_where', $search_where_callback ], 4160 [ 'posts_where', $description_where_callback ], 4161 [ 'posts_where', $short_desc_where_callback ], 4162 [ 'posts_where', $id_where_callback ], 4163 [ 'posts_clauses', $sort_clauses_callback ], 4164 ] as [ $hook, $callback ] ) { 4165 if ( $callback ) { 4166 $scoped_callbacks[] = [ $hook, self::scoped_to( $token, $callback ) ]; 4167 } 4168 } 4169 $has_scoped_callbacks = ! empty( $scoped_callbacks ); 4170 4171 // Marker first — it must be on the query args before WP_Query is built. 4172 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $products_marker_callback, 10, 2 ); 4173 4174 if ( $has_scoped_callbacks ) { 4175 add_filter( 'posts_where', $token_sentinel, 10, 2 ); 3657 4176 } 3658 4177 … … 3666 4185 } 3667 4186 3668 if ( $stock_qty_join_callback ) { 3669 add_filter( 'posts_join', $stock_qty_join_callback, 10, 1 ); 3670 add_filter( 'posts_where', $stock_qty_where_callback, 10, 1 ); 3671 } 3672 3673 if ( $product_url_join_callback ) { 3674 // Marker first — it must be on the query args before WP_Query is built. 3675 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10, 2 ); 3676 add_filter( 'posts_join', $product_url_join_callback, 10, 2 ); 3677 add_filter( 'posts_where', $product_url_where_callback, 10, 2 ); 3678 } 3679 3680 if ( $combined_join_callback ) { 3681 add_filter( 'posts_join', $combined_join_callback, 10, 1 ); 3682 } 3683 if ( $combined_where_callback ) { 3684 add_filter( 'posts_where', $combined_where_callback, 10, 1 ); 3685 } 3686 3687 if ( $search_join_callback ) { 3688 add_filter( 'posts_join', $search_join_callback, 10, 1 ); 3689 } 3690 if ( $search_where_callback ) { 3691 add_filter( 'posts_where', $search_where_callback, 10, 1 ); 3692 } 3693 if ( $description_where_callback ) { 3694 add_filter( 'posts_where', $description_where_callback, 10, 1 ); 3695 } 3696 if ( $short_desc_where_callback ) { 3697 add_filter( 'posts_where', $short_desc_where_callback, 10, 1 ); 3698 } 3699 3700 if ( $id_where_callback ) { 3701 add_filter( 'posts_where', $id_where_callback, 10, 1 ); 3702 } 3703 3704 if ( $sort_clauses_callback ) { 3705 // Marker first — it must be on the query args before WP_Query is built. 3706 add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $sort_marker_callback, 10, 2 ); 3707 add_filter( 'posts_clauses', $sort_clauses_callback, 10, 2 ); 4187 foreach ( $scoped_callbacks as [ $hook, $callback ] ) { 4188 add_filter( $hook, $callback, 10, 2 ); 3708 4189 } 3709 4190 3710 4191 try { 4192 ++self::$products_query_depth; 3711 4193 $results = wc_get_products( $args ); 3712 4194 } finally { 4195 --self::$products_query_depth; 4196 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $products_marker_callback, 10 ); 4197 if ( $has_scoped_callbacks ) { 4198 remove_filter( 'posts_where', $token_sentinel, 10 ); 4199 } 3713 4200 if ( $needs_custom_query ) { 3714 4201 remove_filter( … … 3718 4205 ); 3719 4206 } 3720 if ( $stock_qty_join_callback ) { 3721 remove_filter( 'posts_join', $stock_qty_join_callback, 10 ); 3722 remove_filter( 'posts_where', $stock_qty_where_callback, 10 ); 3723 } 3724 if ( $product_url_join_callback ) { 3725 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $product_url_marker_callback, 10 ); 3726 remove_filter( 'posts_join', $product_url_join_callback, 10 ); 3727 remove_filter( 'posts_where', $product_url_where_callback, 10 ); 3728 } 3729 if ( $combined_join_callback ) { 3730 remove_filter( 'posts_join', $combined_join_callback, 10 ); 3731 } 3732 if ( $combined_where_callback ) { 3733 remove_filter( 'posts_where', $combined_where_callback, 10 ); 3734 } 3735 if ( $search_join_callback ) { 3736 remove_filter( 'posts_join', $search_join_callback, 10 ); 3737 } 3738 if ( $search_where_callback ) { 3739 remove_filter( 'posts_where', $search_where_callback, 10 ); 3740 } 3741 if ( $description_where_callback ) { 3742 remove_filter( 'posts_where', $description_where_callback, 10 ); 3743 } 3744 if ( $short_desc_where_callback ) { 3745 remove_filter( 'posts_where', $short_desc_where_callback, 10 ); 3746 } 3747 if ( $id_where_callback ) { 3748 remove_filter( 'posts_where', $id_where_callback, 10 ); 3749 } 3750 if ( $sort_clauses_callback ) { 3751 remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $sort_marker_callback, 10 ); 3752 remove_filter( 'posts_clauses', $sort_clauses_callback, 10 ); 3753 } 4207 foreach ( $scoped_callbacks as [ $hook, $callback ] ) { 4208 remove_filter( $hook, $callback, 10 ); 4209 } 4210 } 4211 4212 if ( ( $has_scoped_callbacks && ! $token_seen ) || ( $needs_custom_query && ! $wc_args_seen ) ) { 4213 // See the sentinel comment above. Loud beats a silently unfiltered catalogue: 4214 // the same result feeds "select all matching" and every bulk operation. 4215 throw new \RuntimeException( 4216 'WCABE: the product query marker (' . esc_html( self::PRODUCTS_QUERY_VAR ) . ') did not reach the query — ' 4217 . 'a plugin rebuilt the WooCommerce product query args after ours were applied, so the active ' 4218 . 'filters and sort could not be enforced. Refusing to return an unfiltered result set.' 4219 ); 3754 4220 } 3755 4221 … … 3924 4390 3925 4391 $child_join = $negated 3926 ? "LEFT JOIN {$wpdb->postmeta} pmON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )"3927 : "INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id";4392 ? 'LEFT JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . " ON ( p.ID = pm.post_id AND pm.meta_key = '_sku' )" 4393 : 'INNER JOIN ' . self::sql_postmeta_by_post_id( 'pm' ) . ' ON p.ID = pm.post_id'; 3928 4394 $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'"; 3929 4395 … … 4063 4529 4064 4530 /** 4531 * Result of postmeta_post_id_index_hint()'s key detection for the running 4532 * service operation, keyed by postmeta table name (switch_to_blog() changes 4533 * it): true = the `post_id` key exists and a hint may name it, false = it does 4534 * not, it is INVISIBLE / IGNORED, or detection failed. Emptied when the 4535 * outermost operation ends — see enter_postmeta_index_scope(). 4536 * 4537 * @var array<string, bool> 4538 */ 4539 private static array $postmeta_post_id_index = []; 4540 4541 /** 4542 * Nesting depth of running service operations (get_products(), 4543 * get_variations(), stream_products()) — the only lifetime the detection 4544 * result is kept for. 4545 */ 4546 private static int $postmeta_index_scope_depth = 0; 4547 4548 /** 4549 * Open a detection scope for one service operation. The detection result is 4550 * kept only while the outermost scope is open: a static would otherwise 4551 * live as long as the PHP process, and a long-lived one (a WP-CLI loop, an 4552 * Action Scheduler batch, a worker-mode server) would keep pinning a key an 4553 * index plugin or a DBA dropped in the meantime — a hard SQL error, and on the 4554 * main products query an empty grid. Every enter must be paired with 4555 * leave_postmeta_index_scope() in a `finally`. 4556 */ 4557 private static function enter_postmeta_index_scope(): void { 4558 if ( 0 === self::$postmeta_index_scope_depth ) { 4559 self::$postmeta_post_id_index = []; 4560 } 4561 ++self::$postmeta_index_scope_depth; 4562 } 4563 4564 private static function leave_postmeta_index_scope(): void { 4565 self::$postmeta_index_scope_depth = max( 0, self::$postmeta_index_scope_depth - 1 ); 4566 if ( 0 === self::$postmeta_index_scope_depth ) { 4567 self::$postmeta_post_id_index = []; 4568 } 4569 } 4570 4571 /** 4572 * ' FORCE INDEX (post_id)' for a postmeta read that must stay on WP core's 4573 * `post_id` key, or '' when that key is not proven usable in a hint. 4574 * 4575 * Why pin: while InnoDB statistics are stale (right after a bulk import) MySQL 4576 * can move a post_id-keyed postmeta read onto the meta_key index. Measured at 4577 * 20k products / 720k postmeta rows: the regular_price "has a price" probe 4578 * went from ~230ms to ~190s (#249); a chunk-restricted first-meta join 4579 * scans the whole meta key (365ms vs 15ms, #247). Every other postmeta lookup 4580 * by post_id — filter joins, variation child predicates, description / SKU 4581 * searches — takes its table reference from sql_postmeta_by_post_id(). 4582 * 4583 * Why only when present: FORCE INDEX naming a missing key is a hard SQL 4584 * error, and the main products query has no error signal — WP_Query would 4585 * return an empty page, a zero total and an empty matching-id list. The key is 4586 * not guaranteed: "Index WP MySQL For Speed" replaces postmeta's keys with a 4587 * (post_id, meta_key, meta_id) primary key and drops `post_id`. An unpinned 4588 * read is the plan every query had before the hint, so every doubt reads as ''. 4589 * A listed key is not enough either: an INVISIBLE key (MySQL 8) or an IGNORED 4590 * one (MariaDB 10.6+) still appears in SHOW INDEX, but a hint naming it is the 4591 * same "doesn't exist" error — see post_id_key_is_usable(). 4592 * 4593 * Detection reads schema metadata (SHOW INDEX), not product data, so the WC 4594 * CRUD rule does not apply. It goes through run_chunk_query(), whose query() 4595 * return check is the only failure signal to trust (a `query` filter that 4596 * empties the SQL leaves last_result stale). Inside a service operation it 4597 * runs once per table and the result ("no such key" and a failed read both 4598 * cached as false) ends with the operation; outside one nothing is kept, so 4599 * an entry point that forgot its scope costs a SHOW INDEX per call, never a 4600 * stale answer. 4601 * 4602 * Table and connection both come from the global $wpdb — never from a caller's 4603 * instance: every pinned read runs on the global connection (the main query 4604 * through WP_Query, child ordering through run_chunk_query()), and so does the 4605 * detection, so the key is always checked on the table and connection the 4606 * hinted SQL will actually hit. 4607 * 4608 * `wcabe_g2_sql_force_postmeta_index` can only DISABLE the hint: it is applied 4609 * on every call, and the hint needs the detected key AND the filter returning 4610 * exactly true, so no callback can pin a key that is not there. 4611 */ 4612 private static function postmeta_post_id_index_hint(): string { 4613 global $wpdb; 4614 $table = (string) $wpdb->postmeta; 4615 4616 /** 4617 * Filters whether postmeta reads may be pinned to WP core's `post_id` key. 4618 * 4619 * Disable-only: anything but true removes the hint; true has no effect 4620 * when the key was not detected on the table. 4621 * 4622 * @param bool $allow Default true. 4623 * @param string $table The postmeta table name. 4624 */ 4625 if ( true !== apply_filters( 'wcabe_g2_sql_force_postmeta_index', true, $table ) ) { 4626 return ''; 4627 } 4628 4629 if ( ! isset( self::$postmeta_post_id_index[ $table ] ) ) { 4630 // Seeded before the query, so a re-entrant call made while it runs reads 4631 // "no key" (unpinned) instead of detecting again. 4632 self::$postmeta_post_id_index[ $table ] = false; 4633 4634 // The table name is $wpdb's own property, not input. 4635 $rows = self::run_chunk_query( 4636 'SHOW INDEX FROM ' . self::sql_quote_table_name( $table ) . " WHERE Key_name = 'post_id'", 4637 'Could not read the postmeta indexes; postmeta reads run without an index hint.' 4638 ); 4639 4640 self::$postmeta_post_id_index[ $table ] = self::post_id_key_is_usable( $rows ); 4641 } 4642 4643 $usable = self::$postmeta_post_id_index[ $table ]; 4644 if ( 0 === self::$postmeta_index_scope_depth ) { 4645 unset( self::$postmeta_post_id_index[ $table ] ); 4646 } 4647 4648 return $usable ? ' FORCE INDEX (post_id)' : ''; 4649 } 4650 4651 /** 4652 * `{$wpdb->postmeta} AS $alias`, pinned to the `post_id` key where 4653 * postmeta_post_id_index_hint() allows it — the table reference for every 4654 * postmeta row looked up by `$alias.post_id = <a row's id>` (the filter joins, 4655 * the variation child predicates, the variation description / SKU searches). 4656 * 4657 * Why all of them, not only the #249 price probe: stale InnoDB statistics 4658 * can move ANY such lookup onto the meta_key index, and then every probe 4659 * scans the whole meta key — rows × key rows, the ~190s page of #249. Pinned, 4660 * a lookup costs one post_id probe per row, the same order as the scan the 4661 * query already makes over those rows. The plan the pin rules out — driving 4662 * an INNER JOIN from a RARE meta key — saves at most that same order of 4663 * probes, so the trade is bounded either way. Not for a join on meta_id (the 4664 * primary key: sql_join_first_meta()'s outer join) nor for a scan of a whole 4665 * meta key (its unrestricted grouped form), where post_id is the wrong key. 4666 * 4667 * Public because extension filters build the same kind of lookup from a 4668 * `posts_where` hook on the products query — Pro's custom meta "is empty" / 4669 * "is not empty" EXISTS probe (#141). Called there, it runs inside the 4670 * detection scope get_products() / stream_products() already hold. 4671 */ 4672 public static function sql_postmeta_by_post_id( string $alias ): string { 4673 global $wpdb; 4674 return "{$wpdb->postmeta} AS {$alias}" . self::postmeta_post_id_index_hint(); 4675 } 4676 4677 /** 4678 * `$table` as a quoted SQL identifier with one backtick-quoted part per dotted 4679 * segment, so a database-qualified name (`otherdb.wp_postmeta`, as some hosts 4680 * set `$wpdb->postmeta`) names that table rather than one literally called 4681 * "otherdb.wp_postmeta" — a failed detection, and the hint silently gone. 4682 * A part that arrives already backtick-quoted is unwrapped first. 4683 */ 4684 private static function sql_quote_table_name( string $table ): string { 4685 return implode( 4686 '.', 4687 array_map( 4688 static fn( string $part ): string => '`' . str_replace( '`', '``', trim( $part, '`' ) ) . '`', 4689 explode( '.', $table ) 4690 ) 4691 ); 4692 } 4693 4694 /** 4695 * Whether the SHOW INDEX rows of the `post_id` key describe a key an index 4696 * hint may name. The key must be listed, and every row of it (one per 4697 * column) must be neither INVISIBLE (MySQL 8 `Visible`) nor IGNORED (MariaDB 4698 * 10.6+ `Ignored`): FORCE INDEX on such a key fails exactly like a missing one. 4699 * A server that reports neither column (MySQL 5.7, older MariaDB) has neither 4700 * state. Column names are matched case-insensitively, and any value other than 4701 * the usable one reads as unusable. 4702 * 4703 * @param array<int, object>|null $rows run_chunk_query()'s result; null = failed read. 4704 */ 4705 private static function post_id_key_is_usable( ?array $rows ): bool { 4706 if ( empty( $rows ) ) { 4707 return false; 4708 } 4709 4710 foreach ( $rows as $row ) { 4711 $columns = array_change_key_case( (array) $row, CASE_LOWER ); 4712 if ( array_key_exists( 'visible', $columns ) && 'YES' !== strtoupper( (string) $columns['visible'] ) ) { 4713 return false; 4714 } 4715 if ( array_key_exists( 'ignored', $columns ) && 'NO' !== strtoupper( (string) $columns['ignored'] ) ) { 4716 return false; 4717 } 4718 } 4719 4720 return true; 4721 } 4722 4723 /** 4065 4724 * LEFT JOIN fragment resolving ONE postmeta row per product for a meta key, 4066 4725 * under alias `$alias` (a wp_postmeta row: `$alias.meta_value`). … … 4081 4740 * @return string 4082 4741 */ 4083 private static function sql_join_first_meta( \wpdb $wpdb, string $meta_key, string $alias ): string {4742 private static function sql_join_first_meta( \wpdb $wpdb, string $meta_key, string $alias, string $on = '', ?array $ids = null ): string { 4084 4743 $meta_key = esc_sql( $meta_key ); 4085 4086 return " LEFT JOIN ( SELECT d.post_id, MIN( d.meta_id ) AS meta_id FROM {$wpdb->postmeta} d" 4087 . " WHERE d.meta_key = '{$meta_key}' GROUP BY d.post_id ) {$alias}_d ON ( {$alias}_d.post_id = {$wpdb->posts}.ID )" 4744 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4745 $restrict = self::sql_id_restriction( 'd.post_id', $ids ); 4746 // Restricted (chunk-scoped) form: MySQL 8 still picks the meta_key index 4747 // for a several-hundred-id IN list and scans every row of the key 4748 // (measured 365ms vs 15ms at 120k rows of `_stock`), so pin WP core's 4749 // `post_id` key — only where it exists (postmeta_post_id_index_hint()). 4750 $hint = '' === $restrict ? '' : self::postmeta_post_id_index_hint(); 4751 4752 return " LEFT JOIN ( SELECT d.post_id, MIN( d.meta_id ) AS meta_id FROM {$wpdb->postmeta} d{$hint}" 4753 . " WHERE d.meta_key = '{$meta_key}'{$restrict} GROUP BY d.post_id ) {$alias}_d ON ( {$alias}_d.post_id = {$on} )" 4088 4754 . " LEFT JOIN {$wpdb->postmeta} {$alias} ON ( {$alias}.meta_id = {$alias}_d.meta_id ) "; 4755 } 4756 4757 /** 4758 * The id restriction for a grouped derived table: '' for null (unrestricted — 4759 * the catalogue-wide main query and the parent aggregate), ` AND {$col} IN ( … )` 4760 * for a non-empty list, and ` AND 1 = 0` for a list with no positive id — 4761 * "restricted to nothing", never silently widened to the whole catalogue 4762 * (child ordering, #247, keys every chunk-scoped join on this). Values are 4763 * cast to int here, so callers never interpolate raw input. 4764 * 4765 * @param int[]|null $ids 4766 */ 4767 private static function sql_id_restriction( string $col, ?array $ids ): string { 4768 return null === $ids ? '' : ' AND ' . self::sql_id_in( $col, $ids ); 4769 } 4770 4771 /** 4772 * `$col IN ( … )` over the positive ids in $ids, or `1 = 0` when there is 4773 * none — the predicate sql_id_restriction() appends. Values are cast to int. 4774 * 4775 * @param int[] $ids 4776 */ 4777 private static function sql_id_in( string $col, array $ids ): string { 4778 $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), static fn( int $id ): bool => $id > 0 ) ) ); 4779 return empty( $ids ) ? '1 = 0' : "{$col} IN ( " . implode( ',', $ids ) . ' )'; 4780 } 4781 4782 /** 4783 * The parent-side restriction for a chunk's child ordering query (#247): the 4784 * REAL parents of the chunk's variations, resolved in SQL from `post_parent`, 4785 * not the map keys the children were listed under — a `woocommerce_get_children` 4786 * filter may list a child under a parent that is not its own, and the grid 4787 * shows that child's real parent data. Callers pass the returned list as the 4788 * `$ids` of the parent-side sql_join_first_meta() joins; an empty list (no 4789 * child has a real parent) restricts those joins to nothing. 4790 * 4791 * Null when the lookup failed. Read through query()'s return value, never 4792 * get_col(): wpdb returns false before flush() when the `query` filter empties 4793 * the SQL, and get_col() would then read the PREVIOUS query's stale result as 4794 * the parent set. 4795 * 4796 * @param int[] $child_ids 4797 * @return int[]|null 4798 */ 4799 private static function real_parent_ids( \wpdb $wpdb, array $child_ids ): ?array { 4800 $list = implode( ',', array_map( 'intval', $child_ids ) ); 4801 if ( '' === $list ) { 4802 return []; 4803 } 4804 // Int-cast id list; a per-chunk PK read. The leading comment is a stable 4805 // marker for the failure-injection test. 4806 $rows = self::run_chunk_query( 4807 "SELECT /* wcabe_g2_order_variations_parents */ DISTINCT post_parent FROM {$wpdb->posts} WHERE ID IN ({$list}) AND post_parent > 0", 4808 'variation parent lookup failed; keeping the stored child order for this chunk.' 4809 ); 4810 return null === $rows ? null : array_map( static fn( $row ): int => (int) $row->post_parent, $rows ); 4811 } 4812 4813 /** 4814 * Run one chunked variation read — the #218 pre-filter, the #247 parent 4815 * lookup, child ordering and name ordering, the #276 stock-status 4816 * pre-read — and return its rows, or null when it failed. Public for 4817 * Operations_Service's duplicate SKU / slug reads, 4818 * which need the same failure signal. postmeta_post_id_index_hint() (#249) 4819 * reads null as "no post_id key": the one reading whose wrong direction 4820 * would empty the grid, so a contract change must keep it safe too. 4821 * 4822 * query()'s return value is the ONLY failure signal to trust: 4823 * it is false on every failure path (SQL error, emptied by the `query` filter, 4824 * connection not ready), and wpdb returns false BEFORE flush() when the filter 4825 * empties the SQL, leaving last_error '' and last_result stale — so neither of 4826 * those, nor get_col(), may be read instead. Error printing is suppressed for 4827 * the call and restored on every exit path, including a `query` filter that 4828 * throws (the exception still propagates). A failure is logged under WP_DEBUG; 4829 * the caller takes the fallback $on_error describes. 4830 * 4831 * @param string $sql Complete SQL: $wpdb->prepare() output, or built from 4832 * int-cast ids and this class's own fragments — see each 4833 * call site's note. 4834 * @param string $on_error What failed and the fallback the caller takes. 4835 * @return object[]|null 4836 */ 4837 public static function run_chunk_query( string $sql, string $on_error ): ?array { 4838 global $wpdb; 4839 4840 $was_suppressed = $wpdb->suppress_errors( true ); 4841 try { 4842 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- every caller passes prepare() output or SQL built from int-cast ids and this class's own fragments (see each call site); per-request chunked reads, nothing to cache. 4843 $result = $wpdb->query( $sql ); 4844 } finally { 4845 $wpdb->suppress_errors( $was_suppressed ); 4846 } 4847 4848 if ( false === $result ) { 4849 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'wc_get_logger' ) ) { 4850 wc_get_logger()->warning( 'WCABE G2: ' . $on_error . ' ' . $wpdb->last_error, [ 'source' => 'wcabe-g2' ] ); 4851 } 4852 return null; 4853 } 4854 return (array) $wpdb->last_result; 4089 4855 } 4090 4856 … … 4100 4866 * @return string 4101 4867 */ 4102 private static function sql_join_first_term( \wpdb $wpdb, string $taxonomy, string $alias ): string {4868 private static function sql_join_first_term( \wpdb $wpdb, string $taxonomy, string $alias, string $on = '', ?array $ids = null ): string { 4103 4869 $taxonomy = esc_sql( $taxonomy ); 4870 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4871 $restrict = self::sql_id_restriction( 'tr.object_id', $ids ); 4104 4872 4105 4873 return " LEFT JOIN ( SELECT tr.object_id, MIN( t.slug ) AS slug, MIN( t.name ) AS name" … … 4107 4875 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 4108 4876 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 4109 . " WHERE tt.taxonomy = '{$taxonomy}' GROUP BY tr.object_id ) {$alias} ON ( {$alias}.object_id = {$wpdb->posts}.ID) ";4877 . " WHERE tt.taxonomy = '{$taxonomy}'{$restrict} GROUP BY tr.object_id ) {$alias} ON ( {$alias}.object_id = {$on} ) "; 4110 4878 } 4111 4879 … … 4118 4886 * @return string 4119 4887 */ 4120 private static function sql_join_visibility_flags( \wpdb $wpdb ): string { 4888 private static function sql_join_visibility_flags( \wpdb $wpdb, string $on = '', ?array $ids = null ): string { 4889 $on = '' === $on ? "{$wpdb->posts}.ID" : $on; 4890 $restrict = self::sql_id_restriction( 'tr.object_id', $ids ); 4891 4121 4892 return " LEFT JOIN ( SELECT tr.object_id," 4122 4893 . " MAX( t.slug = 'featured' ) AS featured," … … 4126 4897 . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" 4127 4898 . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" 4128 . " WHERE tt.taxonomy = 'product_visibility' GROUP BY tr.object_id ) wcabe_sort_vis ON ( wcabe_sort_vis.object_id = {$wpdb->posts}.ID) ";4899 . " WHERE tt.taxonomy = 'product_visibility'{$restrict} GROUP BY tr.object_id ) wcabe_sort_vis ON ( wcabe_sort_vis.object_id = {$on} ) "; 4129 4900 } 4130 4901 … … 4137 4908 private const SORT_TYPE_ALIAS = 'wcabe_sort_ptype'; 4138 4909 4139 private static function sql_join_product_type( \wpdb $wpdb ): string { 4140 return self::sql_join_first_term( $wpdb, 'product_type', self::SORT_TYPE_ALIAS ); 4910 private static function sql_join_product_type( \wpdb $wpdb, string $on = '', ?array $ids = null ): string { 4911 return self::sql_join_first_term( $wpdb, 'product_type', self::SORT_TYPE_ALIAS, $on, $ids ); 4912 } 4913 4914 /** 4915 * The `( $wpdb, $ctx )` closure context resolved to the row id expression 4916 * and the id restriction the join helpers take. `$ctx` is the array 4917 * resolve_sort_expressions() hands to every spec closure: `row` (table 4918 * alias of the row being ranked) and `ids` (int[]|null — null unrestricted, 4919 * a list restricts, an empty list matches nothing; see sql_id_restriction()). 4920 * Closures that predate #247 declare `( \wpdb $wpdb )` only and never see 4921 * it — fine for a parent-row sort, which is why third-party specs must opt 4922 * in to `child`. 4923 * 4924 * @param array<string, mixed> $ctx 4925 * @return array{0: string, 1: int[]|null} [ on-expression, ids ] 4926 */ 4927 private static function sort_ctx_join_args( \wpdb $wpdb, array $ctx ): array { 4928 $row = (string) ( $ctx['row'] ?? '' ); 4929 $on = '' === $row ? "{$wpdb->posts}.ID" : "{$row}.ID"; 4930 $ids = $ctx['ids'] ?? null; 4931 return [ $on, null === $ids ? null : (array) $ids ]; 4141 4932 } 4142 4933 … … 4194 4985 * (variable parents carry one `_price` row per child) nor from the 4195 4986 * lookup (an empty `_price` coerces to 0.0000, never NULL). 4987 * - child How a VARIATION row's displayed value on this column is derived 4988 * (#247). Feeds BOTH the parent aggregate below and the ordering of 4989 * variations under their parent — per-row form 4990 * child_sort_expressions(), aggregate form 4991 * child_aggregate_expressions(): the same displayed-value 4992 * semantics, each called directly by its consumer. 4993 * 'same' — the spec's own expressions evaluated on the 4994 * variation row (post / lookup / meta / expr). 4995 * 'none' — the grid shows nothing for variations on this 4996 * column: children order by ID in the sort 4997 * direction, no query, no aggregate. 4998 * 'display_name' — ranked on the formatted `name` 4999 * (variation_display_name(): parent title + 5000 * attribute labels, NOT the stored post_title) 5001 * by a literal-list query under the 5002 * `post_title` collation the parent rows sort 5003 * by (order_formatted_children()). 5004 * [ 'inherit' => 'parent_meta', 'meta' => '_weight' ] 5005 * — own meta unless it formats empty() through 5006 * wc_format_decimal() (sql_formats_empty()), 5007 * then the parent's (WC's view-context getters 5008 * for weight / dimensions). 5009 * [ 'inherit' => 'managed_stock', 'meta' => '_stock' | '_backorders', 5010 * 'type' => 'stock_qty' | 'string' ] 5011 * — the parent's value while the child does not 5012 * manage stock and the parent does (WC's 5013 * get_stock_quantity() / get_backorders()). 5014 * Parent-side reads use sql_join_first_meta() on c.post_parent — 5015 * never a plain postmeta join: a duplicate parent row would 5016 * multiply the child rows and hand one child two ranks. A spec 5017 * WITHOUT `child` (third-party) keeps parent-only ranking and WC's 5018 * child order — its closures may hard-code the posts table. 5019 * Closures receive `( $wpdb, $ctx )`; see sort_ctx_join_args(). 5020 * - aggregate 5021 * bool, default true whenever `child` is 'same' or an inherit array 5022 * (never for 'none' / 'display_name'). A VARIABLE parent then ranks 5023 * by the family extreme: MIN (asc) / MAX (desc) over its own 5024 * non-blank value and its variations' non-blank displayed values; 5025 * blank only when the whole family is blank. Gated on the product 5026 * type so leftover variation posts under a converted simple product 5027 * contribute nothing. Aggregates over every publish/private child, 5028 * not the filter-narrowed set (accepted approximation). 4196 5029 * 4197 5030 * Blank rows sort LAST in both directions, mirroring the grid's client-side … … 4225 5058 $specs = [ 4226 5059 // wp_posts columns. 4227 'id' => [ 'kind' => 'post', 'key' => 'ID', 'type' => 'numeric_native' ], 4228 'menu_order' => [ 'kind' => 'post', 'key' => 'menu_order', 'type' => 'numeric_native' ], 4229 'name' => [ 'kind' => 'post', 'key' => 'post_title', 'type' => 'string' ], 4230 'slug' => [ 'kind' => 'post', 'key' => 'post_name', 'type' => 'string' ], 4231 'status' => [ 'kind' => 'post', 'key' => 'post_status', 'type' => 'string' ], 4232 'date_created' => [ 'kind' => 'post', 'key' => 'post_date', 'type' => 'string' ], 4233 'date_modified' => [ 'kind' => 'post', 'key' => 'post_modified', 'type' => 'string' ], 5060 // id / slug / status: variations carry them (child 'same') but a family 5061 // extreme over ids or slugs is meaningless, so no aggregate. id children 5062 // rank by ID in PHP, no query (spec_ranks_children_by_id()). name: the 5063 // grid shows variation_display_name(), not the stored post_title (WC may 5064 // omit the attributes from it), so children are ranked in PHP on the 5065 // formatted name. The two dates are parent-only in format_variation(). 5066 'id' => [ 'kind' => 'post', 'key' => 'ID', 'type' => 'numeric_native', 'child' => 'same', 'aggregate' => false ], 5067 // menu_order: a variation's menu_order is its position WITHIN the parent, 5068 // so a family extreme would be meaningless — children are ordered, the 5069 // parent ranks by its own value. 5070 'menu_order' => [ 'kind' => 'post', 'key' => 'menu_order', 'type' => 'numeric_native', 'child' => 'same', 'aggregate' => false ], 5071 'name' => [ 'kind' => 'post', 'key' => 'post_title', 'type' => 'string', 'child' => 'display_name' ], 5072 'slug' => [ 'kind' => 'post', 'key' => 'post_name', 'type' => 'string', 'child' => 'same', 'aggregate' => false ], 5073 'status' => [ 'kind' => 'post', 'key' => 'post_status', 'type' => 'string', 'child' => 'same', 'aggregate' => false ], 5074 'date_created' => [ 'kind' => 'post', 'key' => 'post_date', 'type' => 'string', 'child' => 'none' ], 5075 'date_modified' => [ 'kind' => 'post', 'key' => 'post_modified', 'type' => 'string', 'child' => 'none' ], 4234 5076 4235 5077 // wc_product_meta_lookup columns (one row per product, PK product_id). … … 4237 5079 // are both SKU-less; lexical ordering under the table collation (typically 4238 5080 // *_ci) puts letters above digits on a descending sort — Woo-native (#189). 4239 'sku' => [ 'kind' => 'lookup', 'key' => 'sku', 'type' => 'string' ], 5081 'sku' => [ 'kind' => 'lookup', 'key' => 'sku', 'type' => 'string', 'child' => 'same' ], 5082 // global_unique_id (GTIN, #250): varchar NULL default '', written from the 5083 // product's own _global_unique_id (variations never inherit it). Same 5084 // shape as sku, aggregate included: GTINs usually sit on the variations, 5085 // so a variable parent without its own ranks by its family's extreme 5086 // instead of sinking to the blanks. 5087 'global_unique_id' => [ 'kind' => 'lookup', 'key' => 'global_unique_id', 'type' => 'string', 'child' => 'same' ], 4240 5088 // stock_quantity is NULL when stock is not managed — blank, matching the 4241 5089 // "Varies"/empty cell. virtual/downloadable are tinyint 0/1. 4242 'stock_quantity' => [ 'kind' => 'lookup', 'key' => 'stock_quantity', 'type' => 'numeric_native' ], 4243 'stock_status' => [ 'kind' => 'lookup', 'key' => 'stock_status', 'type' => 'string' ], 4244 'virtual' => [ 'kind' => 'lookup', 'key' => 'virtual', 'type' => 'numeric_native' ], 4245 'downloadable' => [ 'kind' => 'lookup', 'key' => 'downloadable', 'type' => 'numeric_native' ], 5090 // stock_quantity: a variation row DISPLAYS the parent's quantity while it 5091 // inherits stock management (WC_Product_Variation::get_stock_quantity(), 5092 // view context) — the lookup row holds only its own _stock, so the child 5093 // branch reads postmeta with the inheritance rule instead. 5094 'stock_quantity' => [ 'kind' => 'lookup', 'key' => 'stock_quantity', 'type' => 'numeric_native', 'child' => [ 'inherit' => 'managed_stock', 'meta' => '_stock', 'type' => 'stock_qty' ] ], 5095 'stock_status' => [ 'kind' => 'lookup', 'key' => 'stock_status', 'type' => 'string', 'child' => 'same' ], 5096 // virtual / downloadable: WooCommerce ignores a variable parent's own flag 5097 // (always unchecked, not editable), so the guard blanks it and a variable 5098 // parent ranks by its variations only (#247); one with no variations is 5099 // blank. 5100 'virtual' => [ 'kind' => 'lookup', 'key' => 'virtual', 'type' => 'numeric_native', 'guard' => 'not_variable', 'child' => 'same' ], 5101 'downloadable' => [ 'kind' => 'lookup', 'key' => 'downloadable', 'type' => 'numeric_native', 'guard' => 'not_variable', 'child' => 'same' ], 4246 5102 // Price — mirrors WooCommerce core's order_by_price_{asc,desc}_post_clauses(). 4247 5103 // Ascending ranks a variable parent by its cheapest child (min_price), … … 4251 5107 // NULL. EXISTS is multi-row safe (variable parents carry one `_price` row 4252 5108 // per child) and keeps a literal '0' price a real price. 5109 // 5110 // Children (#247): the same expressions on the variation's own lookup row 5111 // rank variations by their EFFECTIVE (sale-aware `_price`) price while 5112 // the cell shows the regular price — the one documented exception to 5113 // "children sort by what the grid displays", inherited from #184 and 5114 // deliberately not widened. No aggregate: min_price / max_price on the 5115 // parent row already ARE the children's extremes (WC syncs them). 4253 5116 'regular_price' => [ 4254 'kind' => 'lookup', 4255 'key' => 'min_price', 4256 'type' => 'numeric_native', 4257 'key_asc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.min_price', 4258 'key_desc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.max_price', 4259 'blank' => static fn( \wpdb $wpdb ): string => "NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wcabe_price_probe" 4260 . " WHERE wcabe_price_probe.post_id = {$wpdb->posts}.ID" 4261 . " AND wcabe_price_probe.meta_key = '_price'" 4262 . " AND wcabe_price_probe.meta_value <> '' )", 5117 'kind' => 'lookup', 5118 'key' => 'min_price', 5119 'type' => 'numeric_native', 5120 'child' => 'same', 5121 'aggregate' => false, 5122 'key_asc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.min_price', 5123 'key_desc' => static fn( \wpdb $wpdb ): string => self::SORT_LOOKUP_ALIAS . '.max_price', 5124 // The probe is pinned to the post_id key where it exists: under stale 5125 // statistics MySQL moved it to meta_key and a page took ~190s (#249). 5126 'blank' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5127 [ $on ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5128 $hint = self::postmeta_post_id_index_hint(); 5129 return "NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wcabe_price_probe{$hint}" 5130 . " WHERE wcabe_price_probe.post_id = {$on}" 5131 . " AND wcabe_price_probe.meta_key = '_price'" 5132 . " AND wcabe_price_probe.meta_value <> '' )"; 5133 }, 4263 5134 ], 4264 5135 … … 4268 5139 // _sale_price of their own (a stale one can survive a type change), so 4269 5140 // they sort as blank rather than by that stale value. 4270 'sale_price' => [ 'kind' => 'meta', 'key' => '_sale_price', 'type' => 'numeric_text', 'guard' => 'not_variable' ], 4271 'weight' => [ 'kind' => 'meta', 'key' => '_weight', 'type' => 'numeric_text' ], 4272 'length' => [ 'kind' => 'meta', 'key' => '_length', 'type' => 'numeric_text' ], 4273 'width' => [ 'kind' => 'meta', 'key' => '_width', 'type' => 'numeric_text' ], 4274 'height' => [ 'kind' => 'meta', 'key' => '_height', 'type' => 'numeric_text' ], 5141 // The variable parent then ranks by its children's sale prices (#247). 5142 'sale_price' => [ 'kind' => 'meta', 'key' => '_sale_price', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 5143 // Weight / dimensions: a variation DISPLAYS the parent's value while its 5144 // own, as wc_format_decimal() reads it, is empty() — see 5145 // sql_formats_empty() (WC_Product_Variation::get_weight()). 5146 'weight' => [ 'kind' => 'meta', 'key' => '_weight', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_weight' ] ], 5147 'length' => [ 'kind' => 'meta', 'key' => '_length', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_length' ] ], 5148 'width' => [ 'kind' => 'meta', 'key' => '_width', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_width' ] ], 5149 'height' => [ 'kind' => 'meta', 'key' => '_height', 'type' => 'numeric_text', 'child' => [ 'inherit' => 'parent_meta', 'meta' => '_height' ] ], 4275 5150 // Sale dates are stored as UNIX timestamps; format_product() blanks them 4276 5151 // on variable parents, so the guard does the same. 4277 'date_on_sale_from' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_from', 'type' => 'numeric_text', 'guard' => 'not_variable' ],4278 'date_on_sale_to' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_to', 'type' => 'numeric_text', 'guard' => 'not_variable' ],5152 'date_on_sale_from' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_from', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 5153 'date_on_sale_to' => [ 'kind' => 'meta', 'key' => '_sale_price_dates_to', 'type' => 'numeric_text', 'guard' => 'not_variable', 'child' => 'same' ], 4279 5154 4280 5155 // postmeta strings. Both read as 'no' when the row is missing (WC's 4281 5156 // getters default; format_product() renders an unchecked box / "Do not 4282 5157 // allow"), so a meta-less product ranks with the explicit 'no' rows. 4283 'backorders' => [ 'kind' => 'meta', 'key' => '_backorders', 'type' => 'string', 'default' => 'no' ], 4284 'manage_stock' => [ 'kind' => 'meta', 'key' => '_manage_stock', 'type' => 'string', 'default' => 'no' ], 5158 // backorders: a variation DISPLAYS the parent's value while it inherits 5159 // stock management (get_backorders(), view context); manage_stock is 5160 // emitted in edit context — the variation's own flag. 5161 'backorders' => [ 'kind' => 'meta', 'key' => '_backorders', 'type' => 'string', 'default' => 'no', 'child' => [ 'inherit' => 'managed_stock', 'meta' => '_backorders', 'type' => 'string' ] ], 5162 'manage_stock' => [ 'kind' => 'meta', 'key' => '_manage_stock', 'type' => 'string', 'default' => 'no', 'child' => 'same' ], 4285 5163 // External-only fields: format_product() emits '' on every other type. 4286 'button_text' => [ 'kind' => 'meta', 'key' => '_button_text', 'type' => 'string', 'guard' => 'external' ],4287 'product_url' => [ 'kind' => 'meta', 'key' => '_product_url', 'type' => 'string', 'guard' => 'external' ],5164 'button_text' => [ 'kind' => 'meta', 'key' => '_button_text', 'type' => 'string', 'guard' => 'external', 'child' => 'none' ], 5165 'product_url' => [ 'kind' => 'meta', 'key' => '_product_url', 'type' => 'string', 'guard' => 'external', 'child' => 'none' ], 4288 5166 4289 5167 // Taxonomy-backed and derived columns. … … 4293 5171 'key' => static fn( \wpdb $wpdb ): string => self::sql_product_type_expr(), 4294 5172 'type' => 'string', 5173 'child' => 'none', 4295 5174 ], 4296 5175 // Sorted by the class NAME the grid displays, not the stored slug. 5176 // A variation's OWN class ('' = same as parent renders blank — #247 5177 // ranks the inheriting child as blank, as the cell shows). 4297 5178 'shipping_class' => [ 4298 5179 'kind' => 'expr', 4299 'joins' => static fn( \wpdb $wpdb ): string => self::sql_join_first_term( $wpdb, 'product_shipping_class', 'wcabe_sort_ship' ), 5180 'joins' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5181 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5182 return self::sql_join_first_term( $wpdb, 'product_shipping_class', 'wcabe_sort_ship', $on, $ids ); 5183 }, 4300 5184 'key' => static fn( \wpdb $wpdb ): string => 'wcabe_sort_ship.name', 4301 5185 'type' => 'string', 5186 'child' => 'same', 4302 5187 ], 4303 5188 // 0/1 flag; a product with no visibility term at all is simply not featured. … … 4307 5192 'key' => static fn( \wpdb $wpdb ): string => 'COALESCE( wcabe_sort_vis.featured, 0 )', 4308 5193 'type' => 'numeric_native', 5194 'child' => 'none', 4309 5195 ], 4310 5196 // WC encodes visibility as two exclusion terms: both → hidden, … … 4318 5204 . " ELSE 'visible' END )", 4319 5205 'type' => 'string', 5206 'child' => 'none', 4320 5207 ], 4321 5208 // Featured image upload date: the attachment's post_date. The post_type … … 4325 5212 'image_date' => [ 4326 5213 'kind' => 'expr', 4327 'joins' => static fn( \wpdb $wpdb ): string => self::sql_join_first_meta( $wpdb, '_thumbnail_id', 'wcabe_sort_img_meta' ) 4328 . " LEFT JOIN {$wpdb->posts} wcabe_sort_img ON ( wcabe_sort_img.ID = CAST( wcabe_sort_img_meta.meta_value AS UNSIGNED ) AND wcabe_sort_img.post_type = 'attachment' ) ", 5214 'joins' => static function ( \wpdb $wpdb, array $ctx = [] ): string { 5215 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5216 return self::sql_join_first_meta( $wpdb, '_thumbnail_id', 'wcabe_sort_img_meta', $on, $ids ) 5217 . " LEFT JOIN {$wpdb->posts} wcabe_sort_img ON ( wcabe_sort_img.ID = CAST( wcabe_sort_img_meta.meta_value AS UNSIGNED ) AND wcabe_sort_img.post_type = 'attachment' ) "; 5218 }, 4329 5219 'key' => static fn( \wpdb $wpdb ): string => 'wcabe_sort_img.post_date', 4330 5220 'type' => 'string', 5221 'child' => 'same', 4331 5222 ], 4332 5223 ]; … … 4335 5226 // render the column otherwise, and the REST enum rejects the key. 4336 5227 if ( Product_Helper::is_cogs_enabled() ) { 4337 $specs['cogs_value'] = [ 'kind' => 'meta', 'key' => '_cogs_total_value', 'type' => 'numeric_text' ];5228 $specs['cogs_value'] = [ 'kind' => 'meta', 'key' => '_cogs_total_value', 'type' => 'numeric_text', 'child' => 'same' ]; 4338 5229 } 4339 5230 … … 4362 5253 } 4363 5254 4364 private const SORT_KINDS = [ 'post', 'lookup', 'meta', 'expr' ]; 4365 private const SORT_TYPES = [ 'numeric_text', 'numeric_native', 'string' ]; 4366 private const SORT_GUARDS = [ 'not_variable', 'external' ]; 5255 private const SORT_KINDS = [ 'post', 'lookup', 'meta', 'expr' ]; 5256 private const SORT_TYPES = [ 'numeric_text', 'numeric_native', 'string' ]; 5257 private const SORT_GUARDS = [ 'not_variable', 'external' ]; 5258 private const SORT_CHILD_MODES = [ 'same', 'none', 'display_name' ]; 5259 private const SORT_CHILD_INHERIT = [ 'parent_meta', 'managed_stock' ]; 5260 private const SORT_CHILD_TYPES = [ 'stock_qty', 'string' ]; 4367 5261 4368 5262 /** … … 4411 5305 } 4412 5306 } 5307 if ( array_key_exists( 'child', $spec ) ) { 5308 $child = $spec['child']; 5309 if ( is_string( $child ) ) { 5310 if ( ! in_array( $child, self::SORT_CHILD_MODES, true ) ) { 5311 return 'child must be one of ' . implode( '|', self::SORT_CHILD_MODES ) . ' or an inherit array'; 5312 } 5313 } elseif ( is_array( $child ) ) { 5314 if ( ! in_array( $child['inherit'] ?? null, self::SORT_CHILD_INHERIT, true ) ) { 5315 return 'child inherit must be one of ' . implode( '|', self::SORT_CHILD_INHERIT ); 5316 } 5317 if ( ! is_string( $child['meta'] ?? null ) || '' === $child['meta'] ) { 5318 return 'child meta must be a non-empty string'; 5319 } 5320 if ( 'managed_stock' === $child['inherit'] && ! in_array( $child['type'] ?? null, self::SORT_CHILD_TYPES, true ) ) { 5321 return 'child type must be one of ' . implode( '|', self::SORT_CHILD_TYPES ); 5322 } 5323 } else { 5324 return 'child must be a string mode or an inherit array'; 5325 } 5326 } 5327 if ( array_key_exists( 'aggregate', $spec ) ) { 5328 if ( ! is_bool( $spec['aggregate'] ) ) { 5329 return 'aggregate must be a bool'; 5330 } 5331 if ( $spec['aggregate'] && ! self::spec_orders_children_in_sql( $spec ) ) { 5332 return "aggregate requires child 'same' or an inherit array"; 5333 } 5334 } 4413 5335 return null; 5336 } 5337 5338 /** 5339 * Whether a spec's variation rows are ranked by SQL (child 'same' or an 5340 * inherit array) — the precondition for the parent aggregate and for 5341 * order_children_by_sql(). 'none' and 'display_name' are ranked in PHP. 5342 */ 5343 private static function spec_orders_children_in_sql( array $spec ): bool { 5344 $child = $spec['child'] ?? null; 5345 return 'same' === $child || is_array( $child ); 5346 } 5347 5348 /** 5349 * Whether a spec ranks a variation row by nothing but its own ID (the `id` 5350 * column: post kind keyed on `ID`, no overrides). Such children are ordered 5351 * in PHP — the id lists are already in hand — instead of by a query whose 5352 * only effect would be ORDER BY c.ID (#247). Derived from the spec shape, 5353 * not the column id, so a filter that reshapes `id` is never mis-routed. 5354 */ 5355 private static function spec_ranks_children_by_id( array $spec ): bool { 5356 return 'same' === ( $spec['child'] ?? null ) 5357 && 'post' === ( $spec['kind'] ?? null ) 5358 && 'ID' === ( $spec['key'] ?? null ) 5359 && ! isset( $spec['key_asc'] ) 5360 && ! isset( $spec['key_desc'] ) 5361 && ! isset( $spec['blank'] ); 5362 } 5363 5364 /** 5365 * Whether the parent row ranks by the family extreme (#247). 5366 */ 5367 private static function spec_aggregates( array $spec ): bool { 5368 return ( $spec['aggregate'] ?? true ) && self::spec_orders_children_in_sql( $spec ); 5369 } 5370 5371 /** 5372 * WC's wc_string_to_bool() truthy set as a SQL predicate over a `_manage_stock` 5373 * meta column: strtolower(), then byte-exact 'yes' / 'true' / '1'; a missing 5374 * row reads as 'no'. WC itself only ever writes 'yes', but a third-party 5375 * writer storing '1' / 'true' must resolve the same way everywhere this is 5376 * consulted (the variation pre-filter, #218; child sort inheritance, #247). 5377 * 5378 * @param string $col SQL column expression. 5379 * @param bool $exact Byte-exact (CAST AS BINARY) comparison; false keeps the 5380 * collation-dependent form the surface-mode EXISTS relies on. 5381 */ 5382 private static function sql_manages_stock( string $col, bool $exact ): string { 5383 return $exact 5384 ? "( CAST( LOWER( COALESCE( {$col}, 'no' ) ) AS BINARY ) IN ( 'yes', '1', 'true' ) )" 5385 : "( COALESCE( {$col}, 'no' ) IN ( 'yes', '1', 'true' ) )"; 5386 } 5387 5388 /** 5389 * Whether a raw weight / dimension meta value reads as empty() once 5390 * WooCommerce has formatted it — the condition under which a variation's 5391 * view-context getter (get_weight() & co.) shows the PARENT's value (#247). 5392 * WC reads the own value through set_weight() → wc_format_decimal(): 5393 * wc_clean(), then keep only [0-9.-] and drop every dot except a last one 5394 * that still has a character after it. The result is empty() when it is '' 5395 * (no digit, no '-', no surviving dot) or '0' (one '0' digit, nothing else 5396 * from [0-9-], no surviving dot). REGEXP only — REGEXP_REPLACE needs MySQL 8. 5397 * 5398 * "A character after the last dot" is judged after wc_clean(), which 5399 * collapses [\r\n\t ] runs and PHP-trim()s the value, so exactly space, \t, 5400 * \n, \r and \v do not count. The class spells them out: MySQL's [:space:] 5401 * also matches \f and (ICU) no-break space, which WordPress keeps. They are 5402 * raw bytes inside a plain string literal — no backslash escape (MySQL 5.7's 5403 * regex library reads none, and NO_BACKSLASH_ESCAPES would disable the 5404 * string ones) and no CHAR(): a CHAR( … USING utf8mb4 ) carries the 5405 * charset's default collation, and CONCAT()ed with a literal of another 5406 * collation it becomes utf8mb4_bin with no coercibility, an illegal mix 5407 * against the meta column. A lone literal is COERCIBLE, so the column's 5408 * collation always wins. 5409 * 5410 * Not modelled — each changes only which way the inherit decision goes: 5411 * - %XX octets: wc_clean() deletes them first ('0%20' → '0', '%30' → '' 5412 * inherit in WC); here their digits count, so the child keeps its value. 5413 * - Markup: wc_clean() strips tags first; here the markup is ordinary text, 5414 * so [0-9.-] inside a tag counts ('<i class="a-1">0</i>') and markup after 5415 * the last dot keeps that dot alive ('0.<b></b>' formats to '0' and 5416 * inherits in WC; here the child keeps its value). 5417 * - A decimal separator other than '.' (the price setting or the PHP 5418 * locale's): WC turns it into '.' first; here it is an ordinary stripped 5419 * character. Under ',' the values '0,kg' and ',a' keep their own value 5420 * in WC ('0.' / '.') but inherit here. 5421 * - A trailing NUL byte: PHP's trim() drops it, the class cannot carry it 5422 * (MySQL 5.7 compiles the pattern as a C string), so '0.\0' keeps its own 5423 * value here and inherits in WC. 5424 * 5425 * @param string $col SQL column expression holding the raw meta value. 5426 */ 5427 private static function sql_formats_empty( string $col ): string { 5428 $last_dot_survives = "'[.][^.]*[^. \t\n\v\r][^.]*\$'"; 5429 return "( {$col} IS NULL OR ( ( NOT {$col} REGEXP '[0-9-]' OR {$col} REGEXP '^[^0-9-]*0[^0-9-]*\$' )" 5430 . " AND NOT {$col} REGEXP {$last_dot_survives} ) )"; 4414 5431 } 4415 5432 … … 4421 5438 public static function get_sortable_columns(): array { 4422 5439 return array_values( array_unique( array_merge( [ 'id' ], array_keys( self::get_sort_specs() ) ) ) ); 5440 } 5441 5442 /** 5443 * Scope a posts_join / posts_where / posts_clauses callback to the ONE WP_Query 5444 * carrying `$token` under PRODUCTS_QUERY_VAR (see that constant). Every scoped 5445 * callback get_products_wc_crud() registers goes through here, so the guard 5446 * cannot be forgotten on the next one: the callbacks themselves stay guard-free 5447 * and receive only the clause they modify. 5448 * 5449 * @param string $token The invoking get_products_wc_crud() call's token. 5450 * @param callable $callback function( string|array $clause ): string|array 5451 * @return callable( string|array, \WP_Query ): string|array 5452 */ 5453 private static function scoped_to( string $token, callable $callback ): callable { 5454 return static function ( $clause, \WP_Query $query ) use ( $token, $callback ) { 5455 if ( $query->get( self::PRODUCTS_QUERY_VAR ) !== $token ) { 5456 return $clause; 5457 } 5458 return $callback( $clause ); 5459 }; 4423 5460 } 4424 5461 … … 4442 5479 * product_type join with the `type` spec). 4443 5480 * 5481 * The returned callback is NOT scoped to a query by itself — the caller wraps it 5482 * with scoped_to() like every other posts_* callback in get_products_wc_crud(). 5483 * 4444 5484 * @param array<string, mixed> $spec One get_sort_specs() entry. 4445 5485 * @param bool $asc Sort direction. 4446 * @return callable( array , \WP_Query): array5486 * @return callable( array ): array 4447 5487 */ 4448 5488 private static function build_sort_clauses( array $spec, bool $asc ): callable { 4449 return static function ( array $clauses , \WP_Query $query) use ( $spec, $asc ): array {5489 return static function ( array $clauses ) use ( $spec, $asc ): array { 4450 5490 global $wpdb; 4451 5491 4452 if ( ! $query->get( self::SORT_QUERY_VAR ) ) { 4453 return $clauses; 4454 } 4455 4456 $alias = self::SORT_LOOKUP_ALIAS; 4457 $dir = $asc ? 'ASC' : 'DESC'; 4458 $joins = []; 4459 4460 switch ( $spec['kind'] ) { 4461 case 'post': 4462 $raw = "{$wpdb->posts}." . $spec['key']; 4463 break; 4464 case 'lookup': 4465 $joins[ $alias ] = " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$alias}" 4466 . " ON {$wpdb->posts}.ID = {$alias}.product_id "; 4467 $raw = "{$alias}." . $spec['key']; 4468 break; 4469 case 'meta': 4470 $joins['wcabe_sort_pm'] = self::sql_join_first_meta( $wpdb, (string) $spec['key'], 'wcabe_sort_pm' ); 4471 $raw = 'wcabe_sort_pm.meta_value'; 4472 break; 4473 default: 4474 $raw = (string) call_user_func( $spec['key'], $wpdb ); 4475 if ( isset( $spec['joins'] ) ) { 4476 $fragment = (string) call_user_func( $spec['joins'], $wpdb ); 4477 // Keyed on the first alias in the fragment so a shared join 4478 // (visibility flags, product type) is added once. 4479 $joins[ self::first_join_alias( $fragment ) ] = $fragment; 4480 } 4481 break; 4482 } 4483 4484 if ( isset( $spec['default'] ) ) { 4485 $raw = "COALESCE( {$raw}, '" . esc_sql( (string) $spec['default'] ) . "' )"; 4486 } 4487 4488 switch ( $spec['type'] ) { 4489 case 'numeric_text': 4490 $key = "CAST( NULLIF( TRIM( {$raw} ), '' ) AS DECIMAL(24,8) )"; 4491 $blank = "NULLIF( TRIM( {$raw} ), '' ) IS NULL"; 4492 break; 4493 case 'numeric_native': 4494 $key = $raw; 4495 $blank = "{$raw} IS NULL"; 4496 break; 4497 default: 4498 $key = $raw; 4499 $blank = "COALESCE( {$raw}, '' ) = ''"; 4500 break; 4501 } 4502 4503 // Overrides FIRST, guard LAST: the guard wraps whatever key/blank the 4504 // spec ends up with, so a spec combining `guard` with `key_asc` / 4505 // `key_desc` / `blank` keeps the guard (the other order silently 4506 // dropped it — the override replaced the guarded key wholesale). 4507 $key_override = $asc ? ( $spec['key_asc'] ?? null ) : ( $spec['key_desc'] ?? null ); 4508 if ( null !== $key_override ) { 4509 $key = (string) call_user_func( $key_override, $wpdb ); 4510 } 4511 if ( isset( $spec['blank'] ) ) { 4512 $blank = (string) call_user_func( $spec['blank'], $wpdb ); 4513 } 4514 4515 if ( ! empty( $spec['guard'] ) ) { 4516 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb ); 4517 $type_expr = self::sql_product_type_expr(); 4518 $guard = ( 'external' === $spec['guard'] ) 4519 ? "{$type_expr} = 'external'" 4520 : "{$type_expr} NOT IN ( " . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 4521 $key = "( CASE WHEN {$guard} THEN {$key} ELSE NULL END )"; 4522 $blank = "( NOT ( {$guard} ) OR {$blank} )"; 5492 $dir = $asc ? 'ASC' : 'DESC'; 5493 5494 $own = self::resolve_sort_expressions( $spec, $asc, [ 'row' => '', 'ids' => null, 'guard' => true ] ); 5495 $key = $own['key']; 5496 $blank = $own['blank']; 5497 $joins = $own['joins']; 5498 5499 if ( self::spec_aggregates( $spec ) ) { 5500 // Family extreme (#247): a VARIABLE parent ranks by MIN (asc) / MAX 5501 // (desc) over its own non-blank value and its variations' non-blank 5502 // DISPLAYED values. The children are read through ONE grouped derived 5503 // table over every publish/private variation (≤ 1 row per parent, so 5504 // SQL_CALC_FOUND_ROWS cannot change), from the aggregate form of the 5505 // displayed-value expressions (child_aggregate_expressions()). 5506 $child = self::child_aggregate_expressions( $spec, $asc ); 5507 5508 // Gate on the parent's type: a variable→simple conversion can leave 5509 // published variation posts behind (see sql_is_variable_product()), 5510 // and a simple product must never rank by children it does not show. 5511 if ( ! isset( $joins[ self::SORT_TYPE_ALIAS ] ) ) { 5512 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb ); 5513 } 5514 $is_variable = self::sql_product_type_expr() . ' IN ( ' . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 5515 5516 // Keyed LITERALLY — first_join_alias() would pick the first inner 5517 // alias out of this text (e.g. `wcabe_sort_pm_d`) — and inserted AFTER 5518 // the own-row joins: the join de-dup below is a substring check, and 5519 // this fragment contains the outer aliases. 5520 $joins['wcabe_sort_child'] = ' LEFT JOIN ( SELECT c.post_parent,' 5521 . " MIN( CASE WHEN NOT ( {$child['blank']} ) THEN {$child['key']} END ) AS k_min," 5522 . " MAX( CASE WHEN NOT ( {$child['blank']} ) THEN {$child['key']} END ) AS k_max" 5523 . " FROM {$wpdb->posts} c" . implode( '', $child['joins'] ) 5524 . " WHERE c.post_type = 'product_variation' AND c.post_status IN ( 'publish', 'private' )" 5525 . " GROUP BY c.post_parent ) wcabe_sort_child ON ( wcabe_sort_child.post_parent = {$wpdb->posts}.ID ) "; 5526 5527 $agg = 'wcabe_sort_child.' . ( $asc ? 'k_min' : 'k_max' ); 5528 $k = "( CASE WHEN {$is_variable} THEN {$agg} END )"; 5529 $own_nb = "( CASE WHEN NOT ( {$blank} ) THEN {$key} END )"; 5530 $fn = $asc ? 'LEAST' : 'GREATEST'; 5531 // LEAST / GREATEST return NULL when either side is NULL, so the 5532 // COALESCE chain yields the extreme when both exist, the one present 5533 // otherwise, and NULL (blank) only when the whole family is blank. 5534 $key = "COALESCE( {$fn}( {$own_nb}, {$k} ), {$own_nb}, {$k} )"; 5535 $blank = "( {$own_nb} IS NULL AND {$k} IS NULL )"; 4523 5536 } 4524 5537 … … 4536 5549 return $clauses; 4537 5550 }; 5551 } 5552 5553 /** 5554 * One spec → the key / blank SQL and the JOINs they need, for the row named 5555 * by `$ctx['row']` (the posts table when '', or a variation alias such as 5556 * `c`). `$ctx['ids']` restricts the grouped derived tables to those ids so a 5557 * chunk-scoped query never groups the whole catalogue; `$ctx['guard']` applies 5558 * the display guards, which are only meaningful on the parent row. 5559 * 5560 * @param array<string, mixed> $spec 5561 * @param array{row?: string, ids?: int[]|null, guard?: bool} $ctx 5562 * @return array{key: string, blank: string, joins: array<string, string>} 5563 */ 5564 private static function resolve_sort_expressions( array $spec, bool $asc, array $ctx ): array { 5565 global $wpdb; 5566 5567 $row = '' === (string) ( $ctx['row'] ?? '' ) ? $wpdb->posts : (string) $ctx['row']; 5568 [ $on, $ids ] = self::sort_ctx_join_args( $wpdb, $ctx ); 5569 5570 $alias = self::SORT_LOOKUP_ALIAS; 5571 $joins = []; 5572 5573 switch ( $spec['kind'] ) { 5574 case 'post': 5575 $raw = "{$row}." . $spec['key']; 5576 break; 5577 case 'lookup': 5578 $joins[ $alias ] = " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$alias}" 5579 . " ON {$on} = {$alias}.product_id "; 5580 $raw = "{$alias}." . $spec['key']; 5581 break; 5582 case 'meta': 5583 $joins['wcabe_sort_pm'] = self::sql_join_first_meta( $wpdb, (string) $spec['key'], 'wcabe_sort_pm', $on, $ids ); 5584 $raw = 'wcabe_sort_pm.meta_value'; 5585 break; 5586 default: 5587 $raw = (string) call_user_func( $spec['key'], $wpdb, $ctx ); 5588 if ( isset( $spec['joins'] ) ) { 5589 $fragment = (string) call_user_func( $spec['joins'], $wpdb, $ctx ); 5590 // Keyed on the first alias in the fragment so a shared join 5591 // (visibility flags, product type) is added once. 5592 $joins[ self::first_join_alias( $fragment ) ] = $fragment; 5593 } 5594 break; 5595 } 5596 5597 if ( isset( $spec['default'] ) ) { 5598 $raw = "COALESCE( {$raw}, '" . esc_sql( (string) $spec['default'] ) . "' )"; 5599 } 5600 5601 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $raw ); 5602 5603 // Overrides FIRST, guard LAST: the guard wraps whatever key/blank the 5604 // spec ends up with, so a spec combining `guard` with `key_asc` / 5605 // `key_desc` / `blank` keeps the guard (the other order silently 5606 // dropped it — the override replaced the guarded key wholesale). 5607 $key_override = $asc ? ( $spec['key_asc'] ?? null ) : ( $spec['key_desc'] ?? null ); 5608 if ( null !== $key_override ) { 5609 $key = (string) call_user_func( $key_override, $wpdb, $ctx ); 5610 } 5611 if ( isset( $spec['blank'] ) ) { 5612 $blank = (string) call_user_func( $spec['blank'], $wpdb, $ctx ); 5613 } 5614 5615 if ( ! empty( $ctx['guard'] ) && ! empty( $spec['guard'] ) ) { 5616 $joins[ self::SORT_TYPE_ALIAS ] = self::sql_join_product_type( $wpdb, $on, $ids ); 5617 $type_expr = self::sql_product_type_expr(); 5618 $guard = ( 'external' === $spec['guard'] ) 5619 ? "{$type_expr} = 'external'" 5620 : "{$type_expr} NOT IN ( " . self::sql_quoted_list( self::get_variable_product_types() ) . ' )'; 5621 $key = "( CASE WHEN {$guard} THEN {$key} ELSE NULL END )"; 5622 $blank = "( NOT ( {$guard} ) OR {$blank} )"; 5623 } 5624 5625 return [ 5626 'key' => $key, 5627 'blank' => $blank, 5628 'joins' => $joins, 5629 ]; 5630 } 5631 5632 /** 5633 * The key / blank pair for a raw SQL value under one of the spec `type`s. 5634 * 5635 * @return array{0: string, 1: string} [ key, blank ] 5636 */ 5637 private static function sort_type_expressions( string $type, string $raw ): array { 5638 switch ( $type ) { 5639 case 'numeric_text': 5640 return [ "CAST( NULLIF( TRIM( {$raw} ), '' ) AS DECIMAL(24,8) )", "NULLIF( TRIM( {$raw} ), '' ) IS NULL" ]; 5641 case 'numeric_native': 5642 return [ $raw, "{$raw} IS NULL" ]; 5643 default: 5644 return [ $raw, "COALESCE( {$raw}, '' ) = ''" ]; 5645 } 5646 } 5647 5648 /** 5649 * The key / blank SQL for a VARIATION row (alias `c`) on one spec — what the 5650 * grid DISPLAYS for that variation — plus the JOINs needed: the PER-ROW form, 5651 * consumed by order_children_by_sql() (#247). The ids restrict the grouped 5652 * derived tables to the chunk's children / real parents; null means 5653 * unrestricted, as everywhere (sql_id_restriction()) — never "aggregate": the 5654 * parent aggregate has its own entry point, child_aggregate_expressions(). 5655 * Precondition: spec_orders_children_in_sql(). 5656 * 5657 * Parent-side values are read through sql_join_first_meta() on 5658 * `c.post_parent` (lowest meta_id wins): WP does not enforce uniqueness on 5659 * (post_id, meta_key), and a plain postmeta join on a duplicated parent row 5660 * would multiply the child rows and hand one child two ranks. 5661 * 5662 * @param array<string, mixed> $spec 5663 * @param int[]|null $child_ids 5664 * @param int[]|null $parent_ids 5665 * @return array{key: string, blank: string, joins: array<string, string>} 5666 */ 5667 private static function child_sort_expressions( array $spec, bool $asc, ?array $child_ids, ?array $parent_ids ): array { 5668 global $wpdb; 5669 5670 if ( ! is_array( $spec['child'] ) ) { 5671 return self::resolve_sort_expressions( $spec, $asc, [ 'row' => 'c', 'ids' => $child_ids, 'guard' => false ] ); 5672 } 5673 5674 $child = $spec['child']; 5675 $meta = (string) $child['meta']; 5676 $joins = [ 5677 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID', $child_ids ), 5678 'wcabe_sort_par' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_par', 'c.post_parent', $parent_ids ), 5679 ]; 5680 $own = 'wcabe_sort_chm.meta_value'; 5681 $parent = 'wcabe_sort_par.meta_value'; 5682 5683 if ( 'parent_meta' === $child['inherit'] ) { 5684 // WC_Product_Variation::get_weight() & co. (view context): the parent's 5685 // value whenever the own one, read through wc_format_decimal(), is 5686 // empty() — see sql_formats_empty(). 5687 $eff = '( CASE WHEN ' . self::sql_formats_empty( $own ) . " THEN {$parent} ELSE {$own} END )"; 5688 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $eff ); 5689 return [ 5690 'key' => $key, 5691 'blank' => $blank, 5692 'joins' => $joins, 5693 ]; 5694 } 5695 5696 // managed_stock: the parent's value while the child does not manage stock 5697 // and the parent does (get_stock_quantity() / get_backorders(), view 5698 // context — "manages" is WC's wc_string_to_bool() set, see 5699 // sql_manages_stock()). 5700 $joins['wcabe_sort_chms'] = self::sql_join_first_meta( $wpdb, '_manage_stock', 'wcabe_sort_chms', 'c.ID', $child_ids ); 5701 $joins['wcabe_sort_parms'] = self::sql_join_first_meta( $wpdb, '_manage_stock', 'wcabe_sort_parms', 'c.post_parent', $parent_ids ); 5702 $inherits = '( NOT ' . self::sql_manages_stock( 'wcabe_sort_chms.meta_value', true ) 5703 . ' AND ' . self::sql_manages_stock( 'wcabe_sort_parms.meta_value', true ) . ' )'; 5704 5705 if ( 'stock_qty' === $child['type'] ) { 5706 // Mirrors the stock-quantity filter rules: an own _stock row that is 5707 // missing or byte-empty is NULL (no quantity), an inherited parent row 5708 // that is missing or empty reads as 0 (WC's parent-data reader). The 5709 // cast makes '10' rank after '2'; a whitespace-only value casts to 0 5710 // exactly as wc_stock_amount() does. 5711 $own_qty = "CASE WHEN {$own} IS NULL OR OCTET_LENGTH( {$own} ) = 0 THEN NULL ELSE {$own} END"; 5712 $parent_qty = "CASE WHEN {$parent} IS NULL OR OCTET_LENGTH( {$parent} ) = 0 THEN '0' ELSE {$parent} END"; 5713 $eff = "( CASE WHEN {$inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 5714 return [ 5715 'key' => "CAST( TRIM( {$eff} ) AS DECIMAL(24,8) )", 5716 'blank' => "{$eff} IS NULL", 5717 'joins' => $joins, 5718 ]; 5719 } 5720 5721 // string (backorders): a missing OWN row ranks as the spec default ('no', 5722 // the getter's default); a missing PARENT row inherits '' exactly as WC's 5723 // parent-data reader supplies it; an explicitly stored '' survives either 5724 // branch (set_backorders() keeps it) and is blank — derived from the 5725 // effective value, never a constant. 5726 $default = esc_sql( (string) ( $spec['default'] ?? '' ) ); 5727 $eff = "( CASE WHEN {$inherits} THEN COALESCE( {$parent}, '' ) ELSE COALESCE( {$own}, '{$default}' ) END )"; 5728 return [ 5729 'key' => $eff, 5730 'blank' => "( {$eff} = '' )", 5731 'joins' => $joins, 5732 ]; 5733 } 5734 5735 /** 5736 * The child expressions for the PARENT AGGREGATE (unrestricted, catalogue-wide 5737 * derived table) — the one entry point build_sort_clauses() calls. Same 5738 * displayed-value semantics as the per-row form in child_sort_expressions(). 5739 * 5740 * - child 'same': the spec's own expressions on the variation row, exactly 5741 * as the per-row form builds them, unrestricted. 5742 * 5743 * Inherit specs take a shape that needs NO parent-side join: an INHERITING 5744 * child displays the parent's own value, and the parent's own value already 5745 * participates in the family extreme (build_sort_clauses() folds `own_nb` 5746 * in), so such a child contributes nothing new and is emitted as NULL. 5747 * Measured at 20k products / 100k 5748 * variations: the per-row form's four catalogue-wide groupings cost 1.6–2.0s 5749 * per page against ~0.3s here. 5750 * 5751 * - parent_meta: own value unless it formats empty() (sql_formats_empty(): 5752 * inherits → NULL here) — the same predicate as the per-row 5753 * form, with the same documented limits. 5754 * - managed_stock: "child manages" is read from the child's lookup row — 5755 * WC_Product_Data_Store_CPT::get_data_for_lookup_table() 5756 * writes stock_quantity only when `_manage_stock` is 5757 * byte-exactly 'yes', NULL otherwise — so no `_manage_stock` 5758 * grouping is needed. A non-managing child either inherits 5759 * (the parent participates) or shows its own value, which 5760 * WC_Product::validate_props() has cleared to '' / 'no' on 5761 * any WC save (a non-managing PARENT's own backorders is that 5762 * same 'no'). The VALUE is still the child's own meta row 5763 * under the per-row empty rules — never the lookup number, 5764 * which turns an empty `_stock` into 0. Exact for WC-saved 5765 * data; a third-party `_manage_stock = '1'` child manages 5766 * per-row but not here (accepted, documented in CLAUDE.md). 5767 * 5768 * @return array{key: string, blank: string, joins: array<string, string>} 5769 */ 5770 private static function child_aggregate_expressions( array $spec, bool $asc ): array { 5771 global $wpdb; 5772 5773 if ( ! is_array( $spec['child'] ) ) { 5774 return self::resolve_sort_expressions( $spec, $asc, [ 'row' => 'c', 'ids' => null, 'guard' => false ] ); 5775 } 5776 5777 $child = $spec['child']; 5778 $meta = (string) $child['meta']; 5779 5780 if ( 'parent_meta' === $child['inherit'] ) { 5781 $own = 'wcabe_sort_chm.meta_value'; 5782 $eff = '( CASE WHEN ' . self::sql_formats_empty( $own ) . " THEN NULL ELSE {$own} END )"; 5783 [ $key, $blank ] = self::sort_type_expressions( (string) $spec['type'], $eff ); 5784 return [ 5785 'key' => $key, 5786 'blank' => $blank, 5787 'joins' => [ 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID' ) ], 5788 ]; 5789 } 5790 5791 $lookup = self::SORT_LOOKUP_ALIAS; 5792 $joins = [ 5793 $lookup => " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup AS {$lookup} ON c.ID = {$lookup}.product_id ", 5794 'wcabe_sort_chm' => self::sql_join_first_meta( $wpdb, $meta, 'wcabe_sort_chm', 'c.ID' ), 5795 ]; 5796 // The lookup is the manage FLAG only. Its stock_quantity is 5797 // wc_stock_amount( _stock ) — an empty _stock becomes 0 there — so the 5798 // VALUE is read from the child's own meta row with the per-row form's 5799 // empty rule, or the two forms would disagree on a managing child with no 5800 // quantity (blank in the grid and in the ordering, 0 in the lookup). 5801 $manages = "{$lookup}.stock_quantity IS NOT NULL"; 5802 $own = 'wcabe_sort_chm.meta_value'; 5803 5804 if ( 'stock_qty' === $child['type'] ) { 5805 $eff = "( CASE WHEN {$manages} AND {$own} IS NOT NULL AND OCTET_LENGTH( {$own} ) > 0 THEN {$own} END )"; 5806 return [ 5807 'key' => "CAST( TRIM( {$eff} ) AS DECIMAL(24,8) )", 5808 'blank' => "{$eff} IS NULL", 5809 'joins' => $joins, 5810 ]; 5811 } 5812 5813 $default = esc_sql( (string) ( $spec['default'] ?? '' ) ); 5814 $eff = "( CASE WHEN {$manages} THEN COALESCE( {$own}, '{$default}' ) END )"; 5815 return [ 5816 'key' => $eff, 5817 'blank' => "( {$eff} IS NULL OR {$eff} = '' )", 5818 'joins' => $joins, 5819 ]; 4538 5820 } 4539 5821 … … 4680 5962 */ 4681 5963 private static function sql_decimal_range_or_keep( \wpdb $wpdb, string $expr, $min, $max ): string { 4682 return self::sql_range_or_keep( $wpdb, $expr, "'^-?[0-9]{1,9}([.][0-9]+)?$'", 'DECIMAL(12,2)', '%f', $min, $max ); 5964 return self::sql_range_or_keep( $wpdb, $expr, self::SQL_PLAIN_DECIMAL_REGEX, 'DECIMAL(12,2)', '%f', $min, $max ); 5965 } 5966 5967 /** 5968 * The variation stock quantity range test over $expr (the reported quantity, 5969 * already known non-NULL), for either child-predicate mode. Returns '' when no 5970 * range test is safe, i.e. the caller keeps every child with a quantity. 5971 * 5972 * Neither mode may reject a child that variation_matches_filters() accepts. 5973 * PHP compares wc_stock_amount( stored ), so the form depends on which 5974 * `woocommerce_stock_amount` normaliser this request runs 5975 * (stock_amount_normaliser()); non-plain values (huge, exponent form, 5976 * whitespace, non-numeric) are always kept: 5977 * 5978 * normaliser surface (decides parents) candidate (#218; bounds pre-widened ±1) 5979 * intval integer part vs the EXACT bounds DECIMAL(12,2) range-or-keep 5980 * floatval DECIMAL(12,2) range-or-keep ±0.01 DECIMAL(12,2) range-or-keep 5981 * unknown no range test no range test 5982 * 5983 * intval: SUBSTRING_INDEX( x, '.', 1 ) is intval()'s truncation toward zero 5984 * on a plain decimal of at most six fraction digits ('9.9' → 9, '-0.5' → '-0' 5985 * → 0; see SQL_INTVAL_EXACT_DECIMAL_REGEX for why longer fractions are kept), 5986 * so WC-saved data surfaces exactly — no empty parent row, no hidden variation. floatval: the cast 5987 * rounds to cents, at most 0.005 away from PHP's 6-place value, which the 5988 * ±0.01 absorbs. unknown: a custom normaliser can move a value arbitrarily 5989 * far (1000 → 10), so no finite widening is a superset; such sites may get 5990 * empty parent rows but never lose a matching variation. 5991 * 5992 * @param \wpdb $wpdb 5993 * @param string $expr SQL expression yielding the stored string. 5994 * @param int|float|null $min 5995 * @param int|float|null $max 5996 * @param bool $candidate Candidate mode (bounds already widened). 5997 * @return string 5998 */ 5999 private static function sql_stock_qty_bound( \wpdb $wpdb, string $expr, $min, $max, bool $candidate ): string { 6000 $normaliser = self::stock_amount_normaliser(); 6001 6002 if ( 'unknown' === $normaliser ) { 6003 return ''; 6004 } 6005 6006 if ( $candidate ) { 6007 return self::sql_decimal_range_or_keep( $wpdb, $expr, $min, $max ); 6008 } 6009 6010 if ( 'floatval' === $normaliser ) { 6011 return self::sql_decimal_range_or_keep( 6012 $wpdb, 6013 $expr, 6014 null === $min ? null : $min - 0.01, 6015 null === $max ? null : $max + 0.01 6016 ); 6017 } 6018 6019 $numeric = '( ' . $expr . ' REGEXP ' . self::SQL_INTVAL_EXACT_DECIMAL_REGEX . ' )'; 6020 $bound = self::sql_bound( $wpdb, "CAST( SUBSTRING_INDEX( {$expr}, '.', 1 ) AS SIGNED )", $min, $max, '%f' ); 6021 return "( NOT {$numeric} OR {$bound} )"; 6022 } 6023 6024 /** 6025 * Which normaliser wc_stock_amount() applies on this request: 'intval' (WC's 6026 * default, the only callback), 'floatval' (the only callback, or none — then 6027 * WC's NumberUtil::normalize() floatval()s the raw numeric string and rounds 6028 * to 6 places), or 'unknown' for anything else. Read per call: a filter added 6029 * mid-request must not leave a stale classification behind. 6030 * 6031 * @return string 'intval' | 'floatval' | 'unknown' 6032 */ 6033 private static function stock_amount_normaliser(): string { 6034 global $wp_filter; 6035 6036 $hook = $wp_filter['woocommerce_stock_amount'] ?? null; 6037 if ( ! $hook instanceof \WP_Hook || ! $hook->has_filters() ) { 6038 return 'floatval'; 6039 } 6040 6041 $functions = []; 6042 foreach ( $hook->callbacks as $by_priority ) { 6043 foreach ( $by_priority as $callback ) { 6044 $functions[] = $callback['function']; 6045 } 6046 } 6047 6048 if ( 1 === count( $functions ) && is_string( $functions[0] ) ) { 6049 $name = strtolower( ltrim( $functions[0], '\\' ) ); 6050 if ( 'intval' === $name || 'floatval' === $name ) { 6051 return $name; 6052 } 6053 } 6054 6055 return 'unknown'; 4683 6056 } 4684 6057 … … 4766 6139 * and the caller has already widened the bounds. 4767 6140 * 4768 * The inheritance CASEs read the PARENT's _manage_stock / _backorders from 4769 * `$spec['parent_ms_col']` / `$spec['parent_bk_col']` when the caller supplies 4770 * them — the surface caller passes its outer wcabe_ms_meta / wcabe_bk_meta 4771 * aliases, which the correlated EXISTS can reference directly, so the main 6141 * The inheritance CASEs read the PARENT's _manage_stock / _backorders / _stock 6142 * from `$spec['parent_ms_col']` / `$spec['parent_bk_col']` / 6143 * `$spec['parent_stock_col']` when the caller supplies them — the surface caller 6144 * passes its outer wcabe_ms_meta / wcabe_bk_meta / wcabe_stock_meta aliases, 6145 * which the correlated EXISTS can reference directly, so the main 4772 6146 * query does not re-join the same parent rows once per child row scanned. 4773 6147 * When absent (the standalone candidate query has no outer row) the fragment … … 4783 6157 * null (never matches). 4784 6158 * 6159 * Stock quantity and stock status are the two axes whose surface form is NOT 6160 * the collation-bound one: they joined the surface EXISTS in #259 and #273 (with 6161 * no earlier parent selection to preserve), so both are byte-exact in both 6162 * modes. Stock quantity's range test is never tighter than PHP in either — see 6163 * sql_stock_qty_bound() for the per-normaliser forms; stock status mirrors 6164 * PHP's read of the stored row, WooCommerce's instock normalisation included 6165 * (a view-context get_stock_status filter or a duplicate meta row is outside 6166 * it, as on every other axis). 6167 * 4785 6168 * @param array $spec Predicate inputs — see resolve_variation_child_spec(). 4786 * Optional `parent_ms_col` / `parent_bk_col`: SQL column 4787 * expressions for the parent's _manage_stock / _backorders 4788 * already joined by the caller (surface mode only). 6169 * Optional `parent_ms_col` / `parent_bk_col` / 6170 * `parent_stock_col`: SQL column expressions for the 6171 * parent's _manage_stock / _backorders / _stock already 6172 * joined by the caller (surface mode only). 4789 6173 * @param string $mode 'surface' or 'candidate'. 4790 6174 * @return array{joins: string, parts: string[]} … … 4808 6192 // surfaces on a child the narrowing then drops. 4809 6193 $manages = static function ( string $col ) use ( $candidate ): string { 4810 return $candidate 4811 ? "( CAST( LOWER( COALESCE( {$col}, 'no' ) ) AS BINARY ) IN ( 'yes', '1', 'true' ) )" 4812 : "( COALESCE( {$col}, 'no' ) IN ( 'yes', '1', 'true' ) )"; 6194 return self::sql_manages_stock( $col, $candidate ); 4813 6195 }; 4814 6196 4815 6197 if ( ! empty( $spec['cogs'] ) ) { 4816 6198 $col = 'wcabe_child_cogs.meta_value'; 4817 $joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogsON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) ";6199 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_cogs' ) . " ON ( wcabe_child_cogs.post_id = c.ID AND wcabe_child_cogs.meta_key = '_cogs_total_value' ) "; 4818 6200 $bound = $candidate 4819 6201 ? self::sql_decimal_range_or_keep( $wpdb, $col, $spec['cogs']['min'], $spec['cogs']['max'] ) … … 4830 6212 // non-attachment means no row — the same "no match" PHP reaches through 4831 6213 // Product_Helper::get_image_upload_date(). 4832 $joins .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_metaON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) ";6214 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_img_meta' ) . " ON ( wcabe_child_img_meta.post_id = c.ID AND wcabe_child_img_meta.meta_key = '_thumbnail_id' ) "; 4833 6215 $joins .= " INNER JOIN {$wpdb->posts} AS wcabe_child_img ON ( wcabe_child_img.ID = CAST( wcabe_child_img_meta.meta_value AS UNSIGNED ) AND wcabe_child_img.post_type = 'attachment' ) "; 4834 6216 $parts[] = self::sql_bound( $wpdb, 'wcabe_child_img.post_date', $spec['image']['min'], $spec['image']['max'], '%s' ); … … 4837 6219 foreach ( (array) ( $spec['sale'] ?? [] ) as $range ) { 4838 6220 $col = "{$range['child_alias']}.meta_value"; 4839 $joins .= " INNER JOIN {$wpdb->postmeta} AS {$range['child_alias']}ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) ";6221 $joins .= " INNER JOIN " . self::sql_postmeta_by_post_id( $range['child_alias'] ) . " ON ( {$range['child_alias']}.post_id = c.ID AND {$range['child_alias']}.meta_key = '{$range['meta_key']}' ) "; 4840 6222 $bound = $candidate 4841 6223 ? self::sql_timestamp_range_or_keep( $wpdb, $col, $range['min'], $range['max'] ) … … 4850 6232 // _backorders / _stock row must still exist for the inheritance branch, 4851 6233 // where the parent's value is used instead. 4852 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_msON ( wcabe_child_ms.post_id = c.ID AND wcabe_child_ms.meta_key = '_manage_stock' ) ";6234 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_ms' ) . " ON ( wcabe_child_ms.post_id = c.ID AND wcabe_child_ms.meta_key = '_manage_stock' ) "; 4853 6235 4854 6236 $parent_ms_col = $spec['parent_ms_col'] ?? null; 4855 6237 if ( null === $parent_ms_col ) { 4856 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pmsON ( wcabe_child_pms.post_id = c.post_parent AND wcabe_child_pms.meta_key = '_manage_stock' ) ";6238 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pms' ) . " ON ( wcabe_child_pms.post_id = c.post_parent AND wcabe_child_pms.meta_key = '_manage_stock' ) "; 4857 6239 $parent_ms_col = 'wcabe_child_pms.meta_value'; 4858 6240 } … … 4869 6251 4870 6252 if ( ! empty( $spec['backorders'] ) ) { 4871 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_bkON ( wcabe_child_bk.post_id = c.ID AND wcabe_child_bk.meta_key = '_backorders' ) ";6253 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_bk' ) . " ON ( wcabe_child_bk.post_id = c.ID AND wcabe_child_bk.meta_key = '_backorders' ) "; 4872 6254 $parent_bk_col = $spec['parent_bk_col'] ?? null; 4873 6255 if ( null === $parent_bk_col ) { 4874 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pbkON ( wcabe_child_pbk.post_id = c.post_parent AND wcabe_child_pbk.meta_key = '_backorders' ) ";6256 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pbk' ) . " ON ( wcabe_child_pbk.post_id = c.post_parent AND wcabe_child_pbk.meta_key = '_backorders' ) "; 4875 6257 $parent_bk_col = 'wcabe_child_pbk.meta_value'; 4876 6258 } … … 4879 6261 4880 6262 if ( ! empty( $spec['stock_qty'] ) ) { 4881 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_stock ON ( wcabe_child_stock.post_id = c.ID AND wcabe_child_stock.meta_key = '_stock' ) "; 4882 $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pstock ON ( wcabe_child_pstock.post_id = c.post_parent AND wcabe_child_pstock.meta_key = '_stock' ) "; 4883 // Byte-exact empty checks: under PAD SPACE collations NULLIF( col, '' ) 4884 // would turn an own _stock of ' ' into NULL and drop the child, while 4885 // PHP's '' !== ' ' keeps it and wc_stock_amount( ' ' ) yields 0. 4886 $own_qty = 'CASE WHEN wcabe_child_stock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_stock.meta_value ) = 0 THEN NULL ELSE wcabe_child_stock.meta_value END'; 4887 $parent_qty = "CASE WHEN wcabe_child_pstock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_pstock.meta_value ) = 0 THEN '0' ELSE wcabe_child_pstock.meta_value END"; 4888 $qty = "( CASE WHEN {$inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 4889 $parts[] = "( {$qty} IS NOT NULL AND " . self::sql_decimal_range_or_keep( $wpdb, $qty, $spec['stock_qty']['min'], $spec['stock_qty']['max'] ) . ' )'; 6263 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_stock' ) . " ON ( wcabe_child_stock.post_id = c.ID AND wcabe_child_stock.meta_key = '_stock' ) "; 6264 6265 $parent_stock_col = $spec['parent_stock_col'] ?? null; 6266 if ( null === $parent_stock_col ) { 6267 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_pstock' ) . " ON ( wcabe_child_pstock.post_id = c.post_parent AND wcabe_child_pstock.meta_key = '_stock' ) "; 6268 $parent_stock_col = 'wcabe_child_pstock.meta_value'; 6269 } 6270 6271 // Byte-exact in BOTH modes, inheritance included: this predicate 6272 // decides parents only since #259 and has no earlier collation-bound 6273 // selection to preserve, and it must never be tighter than PHP. 6274 // Under PAD SPACE collations NULLIF( col, '' ) would turn an own 6275 // _stock of ' ' into NULL and drop the child, while PHP's '' !== ' ' 6276 // keeps it and wc_stock_amount( ' ' ) yields 0. 6277 $qty_inherits = 'NOT ' . self::sql_manages_stock( 'wcabe_child_ms.meta_value', true ) . ' AND ' . self::sql_manages_stock( $parent_ms_col, true ); 6278 $own_qty = 'CASE WHEN wcabe_child_stock.meta_value IS NULL OR OCTET_LENGTH( wcabe_child_stock.meta_value ) = 0 THEN NULL ELSE wcabe_child_stock.meta_value END'; 6279 $parent_qty = "CASE WHEN {$parent_stock_col} IS NULL OR OCTET_LENGTH( {$parent_stock_col} ) = 0 THEN '0' ELSE {$parent_stock_col} END"; 6280 $qty = "( CASE WHEN {$qty_inherits} THEN {$parent_qty} ELSE {$own_qty} END )"; 6281 $bound = self::sql_stock_qty_bound( $wpdb, $qty, $spec['stock_qty']['min'], $spec['stock_qty']['max'], $candidate ); 6282 $parts[] = '' === $bound ? "( {$qty} IS NOT NULL )" : "( {$qty} IS NOT NULL AND {$bound} )"; 4890 6283 } 4891 6284 } … … 4896 6289 // when 'instock' is selected, every such row must be KEPT; otherwise it 4897 6290 // reports 'instock', which is not selected, and cannot match anyway. 6291 // Byte-exact in BOTH modes (#273): this predicate decides parents only 6292 // since then and has no earlier collation-bound selection to preserve. 6293 // A collation match would surface a parent through a child stored as 6294 // 'OUTOFSTOCK', which PHP reports as instock and drops — an empty row. 4898 6295 $col = 'wcabe_child_ss.meta_value'; 4899 $ joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_ss ON ( wcabe_child_ss.post_id = c.ID AND wcabe_child_ss.meta_key = '_stock_status' )";4900 $ in = self::sql_in( $wpdb, $exact( $col ), $spec['stock_status'] );4901 $ valid = array_values( array_map( 'strval', array_keys( (array) wc_get_product_stock_status_options() ) ));4902 $parts[] = in_array( 'instock', $spec['stock_status'], true ) && ! empty( $valid)4903 ? "( {$in} OR {$col} IS NULL OR NOT " . self::sql_in( $wpdb, $ exact( $col ), $valid) . ' )'6296 $bin = "CAST( {$col} AS BINARY )"; 6297 $joins .= " LEFT JOIN " . self::sql_postmeta_by_post_id( 'wcabe_child_ss' ) . " ON ( wcabe_child_ss.post_id = c.ID AND wcabe_child_ss.meta_key = '_stock_status' ) "; 6298 $in = self::sql_in( $wpdb, $bin, $spec['stock_status'] ); 6299 $parts[] = self::stock_status_child_matches_unset( $spec['stock_status'] ) 6300 ? "( {$in} OR {$col} IS NULL OR NOT " . self::sql_in( $wpdb, $bin, self::valid_stock_statuses() ) . ' )' 4904 6301 : $in; 4905 6302 } … … 4909 6306 'parts' => $parts, 4910 6307 ]; 6308 } 6309 6310 /** 6311 * The stock statuses WooCommerce accepts, as set_stock_status() checks them. 6312 * 6313 * @return string[] 6314 */ 6315 private static function valid_stock_statuses(): array { 6316 return array_values( array_map( 'strval', array_keys( (array) wc_get_product_stock_status_options() ) ) ); 6317 } 6318 6319 /** 6320 * Whether the variation stock-status predicate for $values also matches a 6321 * variation whose stored _stock_status is missing, empty or invalid — the 6322 * rows set_stock_status() reads as 'instock'. True exactly when 'instock' is 6323 * selected and WooCommerce lists its valid statuses. 6324 * 6325 * Shared by build_variation_child_predicates() and the stock-status pre-read 6326 * (stock_status_surface_ids(), #276): the pre-read lists stored rows, so it 6327 * can stand in only for the predicate's plain IN form. 6328 * 6329 * @param string[] $values The selected statuses. 6330 */ 6331 private static function stock_status_child_matches_unset( array $values ): bool { 6332 return in_array( 'instock', $values, true ) && ! empty( self::valid_stock_statuses() ); 6333 } 6334 6335 /** 6336 * The stock-status matches of the combined surface clause (#276), read ONCE 6337 * from the _stock_status rows: `own`, the posts with a _stock_status row in 6338 * $values (collation-bound, as WC's meta_query the own branch replaces), and 6339 * `parents`, the parents of the renderable variations whose row is 6340 * byte-exactly in $values (the child predicate's plain IN form). 6341 * 6342 * Without it, the own branch probes the meta rows of every product and the 6343 * child EXISTS those of every variation of every parent it tests: for a rare 6344 * status, the whole catalogue's meta, one post_id probe at a time. 6345 * 6346 * Both lists are exact for their test, so rows, totals and ids cannot change: 6347 * - `own` replaces the own EXISTS outright: the same rows, the same 6348 * comparison (meta_value IN, under the column's collation), any post type, 6349 * and a duplicate row only repeats an id. 6350 * - `parents` is a necessary condition in FRONT of the child EXISTS, which 6351 * stays the gate, because every active axis must hold on the SAME 6352 * variation. It applies the EXISTS's variation type and status gates and 6353 * keys on the same real post_parent. 6354 * 6355 * Plan: STRAIGHT_JOIN reads postmeta first, by its meta_key condition, and the 6356 * post by primary key. It names no key, so unlike an index hint it cannot fail 6357 * on a missing one: without a meta_key index the read is one scan of postmeta 6358 * per query, never one per row. The post_id pin (CLAUDE.md rule 12) does not 6359 * apply: no row is looked up by post_id here, this is a scan of a whole meta 6360 * key. The collation-bound IN selects a superset of the byte-exact one and 6361 * lets a (meta_key, meta_value) index range-scan where a site has one. 6362 * 6363 * @param string[] $values The selected statuses ($args-derived, as both branches). 6364 * @param array $filters The active filters (for the kill switch). 6365 * @return array{own: int[], parents: int[]}|null Either list may be empty. Null: 6366 * keep both EXISTS — the selection also matches unset rows ('instock'), 6367 * the filter switched the read off, it failed, or more rows than the 6368 * cap matched. 6369 */ 6370 private function stock_status_surface_ids( array $values, array $filters ): ?array { 6371 global $wpdb; 6372 6373 if ( empty( $values ) || self::stock_status_child_matches_unset( $values ) ) { 6374 return null; 6375 } 6376 6377 /** 6378 * Filters whether the stock-status filter with variations reads the 6379 * matching products and variation parents in one pass over the 6380 * _stock_status rows, instead of probing each product and variation. 6381 * 6382 * Default true. The rows do not change either way; return false to keep 6383 * the per-row probes. 6384 * 6385 * @param bool $prefilter Whether to read the matches in one pass. 6386 * @param array $filters The active filters. 6387 */ 6388 if ( ! (bool) apply_filters( 'wcabe_g2_sql_prefilter_stock_status', true, $filters ) ) { 6389 return null; 6390 } 6391 6392 $cap = $this->stock_status_prefilter_cap(); 6393 $renders = "c.post_type = 'product_variation' AND c.post_status IN ( 'publish', 'private' ) AND " 6394 . self::sql_in( $wpdb, 'CAST( wcabe_ss_pre.meta_value AS BINARY )', $values ); 6395 6396 // The leading comment is a stable marker for the failure-injection test. 6397 $rows = self::run_chunk_query( 6398 "SELECT /* wcabe_g2_stock_status_ids */ wcabe_ss_pre.post_id, IF( {$renders}, c.post_parent, 0 ) AS surfaced_parent" 6399 . " FROM {$wpdb->postmeta} AS wcabe_ss_pre" 6400 . " STRAIGHT_JOIN {$wpdb->posts} AS c ON ( c.ID = wcabe_ss_pre.post_id )" 6401 . " WHERE wcabe_ss_pre.meta_key = '_stock_status' AND " . self::sql_in( $wpdb, 'wcabe_ss_pre.meta_value', $values ) 6402 . ' LIMIT ' . ( $cap + 1 ), 6403 'stock status pre-read failed; filtering with the per-row probes.' 6404 ); 6405 6406 if ( null === $rows || count( $rows ) > $cap ) { 6407 return null; 6408 } 6409 6410 $own = []; 6411 $parents = []; 6412 foreach ( $rows as $row ) { 6413 $own[ (int) $row->post_id ] = true; 6414 if ( (int) $row->surfaced_parent > 0 ) { 6415 $parents[ (int) $row->surfaced_parent ] = true; 6416 } 6417 } 6418 6419 return [ 6420 'own' => array_keys( $own ), 6421 'parents' => array_keys( $parents ), 6422 ]; 6423 } 6424 6425 /** 6426 * STOCK_STATUS_PREFILTER_CAP — a method so a test subclass can lower it. 6427 */ 6428 protected function stock_status_prefilter_cap(): int { 6429 return self::STOCK_STATUS_PREFILTER_CAP; 4911 6430 } 4912 6431 … … 5027 6546 5028 6547 return $spec; 6548 } 6549 6550 /** 6551 * Resolve how variations are ordered under their parent for a request's 6552 * sort (#247): null for the default `id desc` (no sort callback runs, WC's 6553 * child order is kept) or an unknown column; otherwise the spec, direction 6554 * and mode. Called once per request by get_products(), stream_products() and 6555 * get_variations() and passed down — it derives the spec table, which is 6556 * deliberately not memoised, so never call it per chunk. 6557 * 6558 * @return array{spec: array<string, mixed>, asc: bool, mode: string}|null 6559 * mode: 'sql' | 'id' (child 'none', or a spec keyed on the row ID 6560 * itself — ranked in PHP, no query) | 'display_name' 6561 */ 6562 private static function resolve_child_sort( string $orderby, string $order ): ?array { 6563 $asc = 'asc' === strtolower( $order ); 6564 if ( 'id' === $orderby && ! $asc ) { 6565 return null; 6566 } 6567 $spec = self::get_sort_specs()[ $orderby ] ?? null; 6568 if ( null === $spec || ! array_key_exists( 'child', $spec ) ) { 6569 return null; 6570 } 6571 if ( self::spec_ranks_children_by_id( $spec ) ) { 6572 // The ids are already in hand — a query would only ORDER BY c.ID. 6573 $mode = 'id'; 6574 } elseif ( self::spec_orders_children_in_sql( $spec ) ) { 6575 $mode = 'sql'; 6576 } else { 6577 $mode = 'none' === $spec['child'] ? 'id' : 'display_name'; 6578 } 6579 return [ 6580 'spec' => $spec, 6581 'asc' => $asc, 6582 'mode' => $mode, 6583 ]; 6584 } 6585 6586 /** 6587 * Order each parent's child-id list for the resolved sort: by SQL rank 6588 * ('sql'), by ID in the sort direction ('id'), or untouched 6589 * ('display_name' — ranked after formatting by order_formatted_children(), 6590 * which honours the same kill switch). 6591 * 6592 * @param array<int, int[]> $children_map 6593 * @param array $filters Passed to the kill-switch filter. 6594 * @return array<int, int[]> 6595 */ 6596 private function order_children_map( array $children_map, ?array $child_sort, array $filters ): array { 6597 if ( null === $child_sort || empty( $children_map ) ) { 6598 return $children_map; 6599 } 6600 if ( 'id' === $child_sort['mode'] ) { 6601 foreach ( $children_map as $parent_id => $children ) { 6602 $children = array_map( 'intval', (array) $children ); 6603 $child_sort['asc'] ? sort( $children, SORT_NUMERIC ) : rsort( $children, SORT_NUMERIC ); 6604 $children_map[ $parent_id ] = $children; 6605 } 6606 return $children_map; 6607 } 6608 if ( 'sql' !== $child_sort['mode'] ) { 6609 return $children_map; 6610 } 6611 /** 6612 * Filters whether variations are ordered under their parent in SQL for 6613 * the active sort (#247). Return false to keep WooCommerce's child order; 6614 * the parent ranking is unaffected. 6615 * 6616 * @param bool $order Whether to run the ordering query. 6617 * @param array $filters The active filters. 6618 */ 6619 if ( ! (bool) apply_filters( 'wcabe_g2_sql_order_variations', true, $filters ) ) { 6620 return $children_map; 6621 } 6622 return $this->order_children_by_sql( $children_map, $child_sort['spec'], $child_sort['asc'] ); 6623 } 6624 6625 /** 6626 * Order each parent's children by the sort column's DISPLAYED value in SQL 6627 * (#247): one chunked query per page over the chunk's child ids, ORDER BY 6628 * the same `(blank) ASC, key <dir>, ID <dir>` contract the parent sort uses, 6629 * with the expressions from child_sort_expressions() restricted to the 6630 * chunk's child / parent ids so no derived table groups the catalogue. 6631 * Chunks hold WHOLE parents — a rank is only meaningful within one parent. 6632 * A failed chunk keeps its parents' original order (a DB error must never 6633 * scramble a page); ids the query did not return keep their relative order 6634 * at the end. Only the ORDER changes: every list comes back with exactly the 6635 * entries it went in with — duplicates and non-positive values included — so 6636 * the rows a page emits never depend on the sort or on a query failing. 6637 * 6638 * @param array<int, int[]> $children_map parent id => child ids. 6639 * @return array<int, int[]> 6640 */ 6641 protected function order_children_by_sql( array $children_map, array $spec, bool $asc ): array { 6642 global $wpdb; 6643 6644 $lists = []; 6645 foreach ( $children_map as $parent_id => $children ) { 6646 $lists[ (int) $parent_id ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $children ), static fn( int $id ): bool => $id > 0 ) ) ); 6647 } 6648 $chunks = self::chunk_whole_parents( $lists, max( 1, $this->narrow_children_chunk_size() ) ); 6649 6650 $dir = $asc ? 'ASC' : 'DESC'; 6651 6652 foreach ( $chunks as $chunk ) { 6653 $child_ids = array_values( array_unique( array_merge( ...array_values( $chunk ) ) ) ); 6654 $parent_ids = null; 6655 if ( is_array( $spec['child'] ) ) { 6656 // Inherit specs read the parent side restricted to the REAL parents. 6657 // A failed lookup must not run the chunk on a guessed parent set 6658 // (unrestricted, or a stale result set): keep its stored order. 6659 $parent_ids = self::real_parent_ids( $wpdb, $child_ids ); 6660 if ( null === $parent_ids ) { 6661 continue; 6662 } 6663 } 6664 $expr = self::child_sort_expressions( $spec, $asc, $child_ids, $parent_ids ); 6665 6666 // NOT $wpdb->prepare(): the spec SQL is interpolated whole, and a `%d` / 6667 // `%s` inside a Pro choice label or ACF default ("5%discount") would read 6668 // as a placeholder, empty the statement and silently keep WC order. Every 6669 // id is int-cast (sql_id_restriction() and the list below), the joins and 6670 // expressions are this class's own fragments over those ids and esc_sql()'d 6671 // keys. The leading comment is a stable marker for the failure-injection test. 6672 $id_list = implode( ',', array_map( 'intval', $child_ids ) ); 6673 $sql = "SELECT /* wcabe_g2_order_variations */ c.ID FROM {$wpdb->posts} c" . implode( '', $expr['joins'] ) 6674 . " WHERE c.ID IN ({$id_list}) AND c.post_type = 'product_variation'" 6675 . " ORDER BY ( {$expr['blank']} ) ASC, {$expr['key']} {$dir}, c.ID {$dir}"; 6676 6677 $rows = self::run_chunk_query( $sql, 'variation ordering query failed; keeping the stored child order for this chunk.' ); 6678 if ( null === $rows ) { 6679 continue; 6680 } 6681 6682 $rank = []; 6683 foreach ( $rows as $row ) { 6684 $id = (int) $row->ID; 6685 if ( ! isset( $rank[ $id ] ) ) { 6686 $rank[ $id ] = count( $rank ); 6687 } 6688 } 6689 6690 // Reorder the ORIGINAL list, not the normalised copy the chunk holds: a 6691 // child sort only changes the order, so a duplicate or non-positive 6692 // entry a `woocommerce_get_children` filter supplied is kept exactly as 6693 // the default sort (and a failed chunk) would keep it. 6694 foreach ( array_keys( $chunk ) as $parent_id ) { 6695 $original = array_values( (array) $children_map[ $parent_id ] ); 6696 $positions = array_keys( $original ); 6697 usort( 6698 $positions, 6699 static function ( int $a, int $b ) use ( $rank, $original ): int { 6700 $ra = $rank[ (int) $original[ $a ] ] ?? PHP_INT_MAX; 6701 $rb = $rank[ (int) $original[ $b ] ] ?? PHP_INT_MAX; 6702 return ( $ra <=> $rb ) ?: ( $a <=> $b ); 6703 } 6704 ); 6705 $children_map[ $parent_id ] = array_map( static fn( int $i ) => $original[ $i ], $positions ); 6706 } 6707 } 6708 6709 return $children_map; 6710 } 6711 6712 /** 6713 * Split per-parent lists into query chunks of WHOLE parents (#247): a rank is 6714 * only meaningful within one parent, so a parent is never split — one with 6715 * more entries than $chunk_size gets a chunk of its own. Lists with fewer 6716 * than two entries have nothing to order and are left out. 6717 * 6718 * @param array<int|string, array<int, mixed>> $lists parent key => entries. 6719 * @return array<int, array<int|string, array<int, mixed>>> chunks, keys preserved. 6720 */ 6721 private static function chunk_whole_parents( array $lists, int $chunk_size ): array { 6722 $chunks = []; 6723 $current = []; 6724 $count = 0; 6725 foreach ( $lists as $key => $list ) { 6726 if ( count( $list ) < 2 ) { 6727 continue; 6728 } 6729 if ( $count > 0 && $count + count( $list ) > $chunk_size ) { 6730 $chunks[] = $current; 6731 $current = []; 6732 $count = 0; 6733 } 6734 $current[ $key ] = $list; 6735 $count += count( $list ); 6736 } 6737 if ( ! empty( $current ) ) { 6738 $chunks[] = $current; 6739 } 6740 return $chunks; 6741 } 6742 6743 /** 6744 * Rank formatted variation rows for a 'display_name' child sort (#247) — the 6745 * emitted `name` (variation_display_name()) is not a stored column, so the 6746 * formatted names go back to MySQL as a literal list and are ordered under 6747 * the EXACT collation the parent rows sort by: the derived table's first 6748 * branch is an empty SELECT of `posts.post_title`, and MySQL's collation 6749 * aggregation gives the column (IMPLICIT) precedence over the literals 6750 * (COERCIBLE), whatever the connection collation. Accent / case equivalents 6751 * and expansions (ß = ss under the UCA collations) therefore tie exactly 6752 * where they tie for parents, then fall to the ID tiebreak; blank (`= ''`, 6753 * PAD SPACE included) sorts last, as the parent-row rule does. 6754 * 6755 * One query per chunk of WHOLE parents (narrow_children_chunk_size() rows), 6756 * built with prepare() — names are arguments, so a `%` in a name is inert. 6757 * A failed chunk keeps its formatted (WooCommerce) order. On a utf8mb3 posts 6758 * table a name holding 4-byte characters can fail the literal-to-column 6759 * conversion — that chunk keeps its order too. Identity for every other mode 6760 * (the id lists were ordered before formatting). 6761 * 6762 * @param array<int|string, array<int, array<string, mixed>>> $groups One row list per parent, keyed as the caller likes. 6763 * @param array $filters Passed to the kill-switch filter. 6764 * @return array<int|string, array<int, array<string, mixed>>> Same keys, rows reordered. 6765 */ 6766 private function order_formatted_children( array $groups, ?array $child_sort, array $filters ): array { 6767 if ( null === $child_sort || 'display_name' !== $child_sort['mode'] || empty( $groups ) ) { 6768 return $groups; 6769 } 6770 /** This filter is documented in Product_Service::order_children_map(). */ 6771 if ( ! (bool) apply_filters( 'wcabe_g2_sql_order_variations', true, $filters ) ) { 6772 return $groups; 6773 } 6774 6775 global $wpdb; 6776 6777 $chunks = self::chunk_whole_parents( $groups, max( 1, $this->narrow_children_chunk_size() ) ); 6778 $dir = $child_sort['asc'] ? 'ASC' : 'DESC'; 6779 6780 foreach ( $chunks as $chunk ) { 6781 $branches = [ "SELECT ID AS id, post_title AS n FROM {$wpdb->posts} WHERE 1 = 0" ]; 6782 $args = []; 6783 foreach ( $chunk as $rows ) { 6784 foreach ( $rows as $row ) { 6785 $branches[] = 'SELECT %d, %s'; 6786 $args[] = (int) ( $row['id'] ?? 0 ); 6787 $args[] = (string) ( $row['name'] ?? '' ); 6788 } 6789 } 6790 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- the branches are `SELECT %d, %s` tokens expanded by prepare() via spread, plus the fixed empty post_title branch. The leading comment is a stable marker for the failure-injection test. 6791 $sql = $wpdb->prepare( 6792 'SELECT /* wcabe_g2_order_variation_names */ t.id FROM ( ' . implode( ' UNION ALL ', $branches ) . ' ) t' 6793 . " ORDER BY ( t.n = '' ) ASC, t.n {$dir}, t.id {$dir}", 6794 ...$args 6795 ); 6796 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 6797 6798 $ordered = self::run_chunk_query( $sql, 'variation name ordering query failed; keeping the stored child order for this chunk.' ); 6799 if ( null === $ordered ) { 6800 continue; 6801 } 6802 6803 $rank = []; 6804 foreach ( $ordered as $row ) { 6805 $id = (int) $row->id; 6806 if ( ! isset( $rank[ $id ] ) ) { 6807 $rank[ $id ] = count( $rank ); 6808 } 6809 } 6810 6811 foreach ( $chunk as $key => $rows ) { 6812 $positions = array_keys( $rows ); 6813 usort( 6814 $positions, 6815 static function ( int $a, int $b ) use ( $rank, $rows ): int { 6816 $ra = $rank[ (int) ( $rows[ $a ]['id'] ?? 0 ) ] ?? PHP_INT_MAX; 6817 $rb = $rank[ (int) ( $rows[ $b ]['id'] ?? 0 ) ] ?? PHP_INT_MAX; 6818 return ( $ra <=> $rb ) ?: ( $a <=> $b ); 6819 } 6820 ); 6821 $groups[ $key ] = array_map( static fn( int $i ): array => $rows[ $i ], $positions ); 6822 } 6823 } 6824 6825 return $groups; 5029 6826 } 5030 6827 … … 5123 6920 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 5124 6921 5125 // Suppress only the printing of a failed query. query() returns false on 5126 // EVERY failure path (SQL error, emptied by the `query` filter, connection 5127 // not ready); only a non-false return guarantees last_result is fresh. 5128 $was_suppressed = $wpdb->suppress_errors( true ); 5129 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is the return value of $wpdb->prepare() above; a per-request pre-filter over a chunked IN list, nothing to cache. 5130 $result = $wpdb->query( $sql ); 5131 $wpdb->suppress_errors( $was_suppressed ); 5132 5133 if ( false === $result ) { 5134 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'wc_get_logger' ) ) { 5135 wc_get_logger()->warning( 'WCABE G2: variation pre-filter query failed; hydrating the full child lists for this chunk. ' . $wpdb->last_error, [ 'source' => 'wcabe-g2' ] ); 5136 } 6922 $rows = self::run_chunk_query( $sql, 'variation pre-filter query failed; hydrating the full child lists for this chunk.' ); 6923 if ( null === $rows ) { 5137 6924 foreach ( $chunk as $child_id ) { 5138 6925 foreach ( $child_parents[ $child_id ] as $parent_id ) { … … 5143 6930 } 5144 6931 5145 foreach ( (array) $wpdb->last_resultas $row ) {6932 foreach ( $rows as $row ) { 5146 6933 $survivor_set[ (int) $row->ID ] = true; 5147 6934 } … … 5182 6969 // TODO: Phase 5 — consider capping variations per product when include_variations is on, 5183 6970 // for extreme edge cases (5000+ variations on a single product). 5184 private function interleave_variations( array $formatted_products, array $filters = [], array $children_map = [] ): array {6971 private function interleave_variations( array $formatted_products, array $filters = [], array $children_map = [], ?array $child_sort = null ): array { 5185 6972 $result = []; 5186 6973 $has_meta_filter = $this->has_variation_meta_filters( $filters ); … … 5189 6976 $attribute_slugs = $this->resolve_attribute_filter_slugs( $filters['attribute_filter'] ?? [] ); 5190 6977 5191 foreach ( $formatted_products as $product ) { 5192 $result[] = $product; 5193 6978 // Two passes: format every parent's variation rows first, so the 6979 // formatted-name sort can rank the whole page in chunked queries (#247), 6980 // then emit parents and their rows in page order. 6981 $groups = []; 6982 foreach ( $formatted_products as $index => $product ) { 5194 6983 // variation_count is only non-zero for variable-capable products 5195 6984 // (format_product gates it on instanceof WC_Product_Variable), so it … … 5241 7030 } 5242 7031 7032 $rows = []; 5243 7033 foreach ( $children as $child_id ) { 5244 7034 $variation = wc_get_product( $child_id ); … … 5247 7037 continue; 5248 7038 } 5249 $r esult[] = Product_Helper::format_variation( $variation );7039 $rows[] = Product_Helper::format_variation( $variation ); 5250 7040 } 5251 7041 } 7042 $groups[ $index ] = $rows; 7043 } 7044 } 7045 7046 // $children arrived already ordered for SQL / id child sorts; only the 7047 // formatted-name sort needs the rows themselves (#247). 7048 $groups = $this->order_formatted_children( $groups, $child_sort, $filters ); 7049 7050 foreach ( $formatted_products as $index => $product ) { 7051 $result[] = $product; 7052 foreach ( $groups[ $index ] ?? [] as $row ) { 7053 $result[] = $row; 5252 7054 } 5253 7055 } … … 5256 7058 } 5257 7059 5258 // TODO: If post-launch profiling shows repeated queries across 5259 // filter-change cycles are expensive on large stores, cache the 5260 // result in a transient keyed on md5(serialize($filters)) with 5261 // ~60s TTL. React Query already handles same-filter pagination 5262 // client-side, so this would only help the filter-churn case. 5263 /** 5264 * Count total rows (parents + variations) that would be rendered if 5265 * include_variations=true for the current filter set, across ALL matching 5266 * pages. Only the variable-parents' children contribute extra rows; simple, 5267 * grouped, and external products already count as one row each in $total. 5268 * 5269 * Returns an approximate flag because the raw COUNT(*) does not replicate 5270 * variation_matches_filters() — variation-level filters (price, stock_qty, 7060 /** 7061 * Count the variation rows the grid renders under the given variable parents: 7062 * their publish/private variations, all of them. Simple, grouped and external 7063 * products add no rows beyond their own, which the page total already counts. 7064 * 7065 * Every variation counts, matching or not: the raw COUNT(*) does not replicate 7066 * variation_matches_filters(), so variation-level filters (price, stock_qty, 5271 7067 * menu_order, manage_stock, backorders, search_description, search_sku, 5272 * attribute_filter) cause an over-count at render time. Accepted as a safe 5273 * upper bound. 5274 * 5275 * @param int $total_parents Total parents matching filters (from main query). 5276 * @param array $filters Same filters passed to get_products_wc_crud(). 5277 * @return array{total: int, is_approximate: bool} 5278 */ 5279 private function get_total_with_variations( int $total_parents, array $filters ): array { 5280 $variable_parent_ids = $this->get_matching_variable_parent_ids( $filters ); 5281 5282 if ( empty( $variable_parent_ids ) ) { 5283 return [ 5284 'total' => $total_parents, 5285 'is_approximate' => false, 5286 ]; 5287 } 5288 7068 * attribute_filter) over-count. Accepted as a safe upper bound; the caller 7069 * flags it through has_variation_meta_filters(). 7070 * 7071 * The client caches the result per filter set (#274), so page and sort changes 7072 * never re-run it. A server-side cache would only help repeated filter changes. 7073 * 7074 * @param int[] $variable_parent_ids From get_matching_variable_parent_ids(). 7075 * @return int 7076 */ 7077 private function count_renderable_variations( array $variable_parent_ids ): int { 5289 7078 global $wpdb; 5290 7079 $variation_count = 0; … … 5301 7090 // renders those. Counting draft/pending here would over-report rows 5302 7091 // that never display. Parent status filtering is already applied via 5303 // the parent-ID query above; this clause is the independent7092 // get_matching_variable_parent_ids(); this clause is the independent 5304 7093 // VARIATION-status axis in WC's data model. 5305 7094 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a comma-joined string of `%d` placeholders, one per element of $chunk, all expanded by prepare() via spread. … … 5316 7105 } 5317 7106 5318 return [ 5319 'total' => $total_parents + $variation_count, 5320 'is_approximate' => $this->has_variation_meta_filters( $filters ), 5321 ]; 7107 return $variation_count; 5322 7108 } 5323 7109 … … 5757 7543 continue; 5758 7544 } 5759 $resolved[ sanitize_title( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );7545 $resolved[ Attribute_Service::attribute_key( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) ); 5760 7546 } 5761 7547 return $resolved; -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/View_Service.php
r3663491 r3736631 140 140 } 141 141 142 update_user_meta( $user_id, 'wcabe_g2_views', $views );142 $this->persist( $user_id, $views ); 143 143 144 144 return $view; … … 203 203 // create_view sanitize on the touched view is sufficient since other 204 204 // views were already sanitized at their own create/update time. 205 update_user_meta( $user_id, 'wcabe_g2_views', $views );205 $this->persist( $user_id, $views ); 206 206 207 207 return $views[ $index ]; … … 241 241 // create_view sanitize on the touched view is sufficient since other 242 242 // views were already sanitized at their own create/update time. 243 update_user_meta( $user_id, 'wcabe_g2_views', $views );243 $this->persist( $user_id, $views ); 244 244 245 245 return true; … … 374 374 } 375 375 376 // Category mode (#147). Same canonical form as the client's 377 // effectiveCategoryOperator(): an exclude mode without categories means nothing, 378 // and would make a freshly loaded view (or a replayed filter draft, which never 379 // passes through the store's apply()) report a phantom "Modified". 380 $category = array_map( 'absint', (array) ( $filters['category'] ?? [] ) ); 381 $category_operator = $filters['category_operator'] ?? 'in'; 382 if ( ! in_array( $category_operator, Product_Service::CATEGORY_OPERATORS, true ) || empty( $category ) ) { 383 $category_operator = 'in'; 384 } 385 376 386 // Product URL filter. Canonicalized here as well as in the client store, 377 387 // because a view can also be created/imported straight through the REST … … 396 406 'search_sku' => $search_sku, 397 407 'search_sku_mode' => $search_sku_mode, 398 'category' => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ), 408 'category' => $category, 409 'category_operator' => $category_operator, 399 410 'tag' => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ), 400 411 'type' => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ), … … 520 531 521 532 /** 533 * The single writer for the per-user views collection. 534 * 535 * `update_metadata()` (which `update_user_meta()` wraps) runs `wp_unslash()` 536 * on the value before storing it, while REST JSON params arrive unslashed. 537 * Without the compensating `wp_slash()` any backslash in a saved filter value 538 * (e.g. a name search for `C:\temp`) would be dropped on write — and because 539 * every write rewrites the WHOLE collection, creating, renaming or deleting 540 * one view would strip backslashes from every other view too (#240). 541 * 542 * @param array<int, array<string, mixed>> $views 543 */ 544 private function persist( int $user_id, array $views ): void { 545 update_user_meta( $user_id, 'wcabe_g2_views', wp_slash( $views ) ); 546 } 547 548 /** 522 549 * Ensure only the given view ID is marked as default. 523 550 * -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/readme.txt
r3700717 r3736631 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1. 3.07 Stable tag: 1.4.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 31 31 * **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style 32 32 * **Bulk operations** — apply percentage price adjustments, find-and-replace, text append and prepend, and bulk category and tag assignment across many products at once 33 * **Wide field coverage** — prices, stock, SKUs, slugs, statuses, categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more33 * **Wide field coverage** — prices, stock, SKUs, GTIN / UPC / EAN / ISBN, slugs, statuses, descriptions (with a visual editor), categories, tags, shipping class, menu order, upsells and cross-sells, product URL and button text for external products, and more 34 34 * **Saved views** — save filter sets and column layouts as named views for one-click recall, and share them between sites with view import/export 35 * **Filters** — filter by category , tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more35 * **Filters** — filter by category (include or exclude), tags, product type, stock status, name and SKU (with exact/contains match modes and CSV SKU lists), description, dates created, modified, on sale, or image uploaded, and more 36 36 * **Column management** — show, hide, reorder, resize, and pin columns; each user's layout is saved separately 37 * **Whole-catalog sorting** — sort any column and the entire catalog is ordered across pages, not just the products on the current page; numbers sort as numbers and empty values stay at the end37 * **Whole-catalog sorting** — sort any column and the entire catalog is ordered across pages, not just the products on the current page; numbers sort as numbers, empty values stay at the end, and variable products rank by their variations 38 38 * **Keyboard shortcuts** — navigate and edit without touching the mouse; arrow keys move between cells even while you are typing 39 39 * **Product operations** — duplicate and change status right from the grid … … 54 54 = Pro version = 55 55 56 If you need more — paste from Excel/Google Sheets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/).56 If you need more — paste from Excel/Google Sheets, an Edit mode that applies one cell edit to every selected product, saved Bulk Edit presets, Operation History with restore, regex find-and-replace, price rounding, custom and ACF meta field editing, custom taxonomies, bulk variation creation, variation attribute editing, WPML support, fine-grained selection tools for the current grid page, and more — there is a paid Pro version available at [wpmelon.com](https://wpmelon.com/bulk-edit/). 57 57 58 58 The free version is genuinely useful on its own. Pro is for stores that need more advanced bulk-editing workflows or fields the free version doesn’t touch. … … 134 134 135 135 == Changelog == 136 137 = 1.4.0 = 138 * Added a visual editor for Description and Short Description, with Visual and HTML tabs. Format text, lists, headings, links and alignment without touching HTML; tables, embedded videos and other advanced HTML are kept exactly as they are. 139 * Added a GTIN column for WooCommerce's GTIN, UPC, EAN, or ISBN field on products and variations: edit it inline or in Bulk Edit, sort by it, and export it. Values WooCommerce would reject, such as a GTIN another product already uses, are flagged on the cell. 140 * The Category filter can now exclude categories with "Is none of". CSV export and saved views keep the choice. 141 * Sorting now takes variations into account: a variable product ranks by the lowest or highest value across its variations, and the variations under each product follow the same order, including in CSV exports. 142 * Duplicated products now behave like WooCommerce's own Duplicate: copies get a unique SKU based on the original (ABC-1, ABC-2), start with no reviews, skip fields other plugins mark as "don't copy", and no longer copy the GTIN. Duplicating a variable product many times is much faster. 143 * Pages with "Include variations" on load faster, especially with the Stock filter, and the "Across all pages" count loads separately. 144 * Fixed the grid showing values from before a save on sites where LiteSpeed Cache or another page cache stored the plugin's responses. 145 * Fixed ascending sorts failing with "The response is not a valid JSON response" on sites where another plugin redirects requests containing an `order` parameter, and price sorts taking minutes right after a large import. 146 * Fixed embedded videos, iframes and other HTML being removed from descriptions when an administrator saved them, and description cells showing codes such as ` `. 147 * Fixed category pickers checking every subcategory when you check a parent. Alt+click (Option+click on a Mac) now checks a category together with its subcategories. 148 * Fixed attributes with non-Latin names (for example "Цвет") showing no value in the grid, in variation names and in CSV exports. 149 * Fixed the Stock and Stock quantity filters with "Include variations" hiding matching variations or showing products that don't match. 150 * Fixed filtered CSV exports ignoring the ID and Description filters, and exported attribute headers not matching the grid. 151 * Fixed the grid's filters leaking into product queries run by other plugins while the grid loads. 152 * Pressing Esc to close a popover or dialog no longer deselects your rows, and reopening a category, tag or attribute cell without changes no longer discards its unsaved edit. 153 * Bulk Edit no longer marks products as edited when an operation leaves their value unchanged, and Cost of Goods edits now warn instead of silently doing nothing when WooCommerce's Cost of Goods feature is off. 154 * Fixed variations without their own SKU showing the parent's SKU, new categories being merged into a same-named category under a different parent, and saved views losing backslashes in filter values. 155 * Users without permission to manage products no longer see the plugin's sortable column names in REST API error messages. 136 156 137 157 = 1.3.0 = … … 217 237 == Upgrade Notice == 218 238 239 = 1.4.0 = 240 Visual description editor, GTIN column, Category "Is none of" filter, variation-aware sorting, WooCommerce-style duplicates, page-cache fixes, and many fixes. 241 219 242 = 1.3.0 = 220 243 Whole-catalog sorting on every column, product Slug editing, CSV delimiter choice and grid-order export, arrow-key navigation while editing, YITH bundle support, and many fixes. -
wpmelon-advanced-bulk-edit-for-woocommerce/trunk/wpmelon-advanced-bulk-edit-for-woocommerce.php
r3700717 r3736631 4 4 * Plugin URI: https://wpmelon.com/bulk-edit/ 5 5 * Description: Excel-like bulk editing interface for WooCommerce products 6 * Version: 1. 3.06 * Version: 1.4.0 7 7 * Author: WPMelon 8 8 * Author URI: https://wpmelon.com/ … … 14 14 * Requires Plugins: woocommerce 15 15 * WC requires at least: 10.0 16 * WC tested up to: 11. 1.016 * WC tested up to: 11.2.0 17 17 * License: GPLv2 or later 18 18 * License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 126 126 define( 'WCABE_G2_LOADED', true ); 127 127 128 define( 'WCABE_G2_VERSION', '1. 3.0' );128 define( 'WCABE_G2_VERSION', '1.4.0' ); 129 129 define( 'WCABE_G2_PLUGIN_FILE', __FILE__ ); 130 130 define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)