Plugin Directory

Changeset 3736631


Ignore:
Timestamp:
10/09/2026 12:30:15 PM (2 weeks ago)
Author:
wpmelon
Message:

Release 1.4.0

Location:
wpmelon-advanced-bulk-edit-for-woocommerce
Files:
14 added
6 deleted
32 edited
1 copied

Legend:

Unmodified
Added
Removed
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Admin.php

    r3700717 r3736631  
    8585                        'isPro'             => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO,
    8686                        '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' ),
    8791                        // Whether this site's woocommerce_stock_amount filter truncates a
    8892                        // decimal stock quantity to a whole number (true on a default
    … …  
    150154
    151155        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';
    153157
    154158                if ( ! file_exists( $manifest_path ) ) {
    … …  
    181185                }
    182186
    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.
    184193                wp_enqueue_script(
    185194                        'wcabe-g2-admin',
    186195                        $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,
    189198                        true
    190199                );
     200                // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion
    191201
    192202                wp_set_script_translations(
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Handlers/Attribute_Handler.php

    r3663491 r3736631  
    3535                add_filter( 'wcabe_g2_filter_options', [ $this, 'inject_attribute_terms' ] );
    3636                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' ] );
    3740        }
    3841
    … …  
    141144                        $data_key = 'attr_' . $taxonomy;
    142145
    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 ) ) {
    144150                                $data[ $data_key ] = [
    145151                                        'terms'     => [],
    … …  
    150156                        }
    151157
    152                         $wc_attr   = $attributes[ $taxonomy ];
     158                        $wc_attr   = $attributes[ $map_key ];
    153159                        $term_ids  = $wc_attr->get_options();
    154160                        $terms     = [];
    … …  
    192198         *
    193199         * 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
    195204         * means "Any" — the variation applies to all terms of that attribute.
    196205         *
    … …  
    212221                        $taxonomy = $attr['taxonomy'];
    213222                        $data_key = 'attr_' . $taxonomy;
    214                         $slug     = $var_attrs[ $taxonomy ] ?? '';
     223                        $slug     = $var_attrs[ Attribute_Service::attribute_key( $taxonomy ) ] ?? '';
    215224
    216225                        if ( '' === $slug ) {
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Handlers/Attribute_Save_Handler.php

    r3663491 r3736631  
    108108                }
    109109
     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
    110132                $lookup = self::get_field_lookup();
    111133
    … …  
    196218                        $attr_def  = $lookup[ $data_key ];
    197219                        $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 );
    198222                        $terms     = $value['terms'];
    199223                        $visible   = isset( $value['visible'] ) ? (bool) $value['visible'] : true;
    … …  
    205229                        if ( empty( $term_ids ) ) {
    206230                                // No terms — remove the attribute from the product.
    207                                 unset( $attributes[ $taxonomy ] );
     231                                unset( $attributes[ $map_key ] );
    208232                                $modified = true;
    209233                                continue;
    … …  
    213237                        // Clone existing attributes so WC's set_prop() detects the change
    214238                        // (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 ];
    217241                        } else {
    218242                                $wc_attr = new \WC_Product_Attribute();
    … …  
    234258                        $wc_attr->set_variation( $variation );
    235259
    236                         $attributes[ $taxonomy ] = $wc_attr;
    237                         $modified = true;
     260                        $attributes[ $map_key ] = $wc_attr;
     261                        $modified               = true;
    238262                }
    239263
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Helpers/Product_Helper.php

    r3700717 r3736631  
    77
    88namespace WCABE_G2\Helpers;
     9
     10use WCABE_G2\Services\Attribute_Service;
    911
    1012defined( 'ABSPATH' ) || exit;
    … …  
    264266                        'slug'                   => urldecode( $product->get_slug() ),
    265267                        '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' ),
    266271                        'type'                   => $product->get_type(),
    267272                        'status'                 => $product->get_status(),
    … …  
    407412                ksort( $attrs );
    408413                $term_names = [];
    409                 foreach ( $attrs as $taxonomy => $slug ) {
     414                foreach ( $attrs as $key => $slug ) {
    410415                        if ( '' === $slug ) {
    411416                                continue; // Skip "Any" attributes.
    412417                        }
    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;
    414422                        if ( $term && ! is_wp_error( $term ) ) {
    415423                                $term_names[] = $term->name;
    … …  
    694702                        // urldecode(): see format_product() — readable form of a percent-encoded slug.
    695703                        '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' ),
    697713                        'type'                   => 'variation',
    698714                        'status'                 => $variation->get_status(),
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Main.php

    r3663491 r3736631  
    4242                new Handlers\Debug_Trace_Handler();
    4343
     44                // Page-cache opt-out — keeps cache plugins from serving stale REST responses.
     45                new Handlers\Rest_Page_Cache_Handler();
     46
    4447                // WordPress save-hook compatibility — Free feature, controlled by plugin setting.
    4548                new Handlers\WP_Save_Hooks_Handler();
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/Export_Controller.php

    r3700717 r3736631  
    1111use WCABE_G2\Helpers\Product_Helper;
    1212use WCABE_G2\Services\Export_Service;
    13 use WCABE_G2\Services\Filter_Service;
    1413use WCABE_G2\Services\Product_Service;
    1514use WCABE_G2\Services\View_Service;
    … …  
    8685                                                'sanitize_callback' => 'sanitize_text_field',
    8786                                        ],
     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                                        ],
    88105                                        'product_url_operator' => [
    89106                                                'description'       => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    104121                                                'sanitize_callback' => 'sanitize_text_field',
    105122                                        ],
     123                                        'category_operator'  => Products_Controller::category_operator_arg(),
    106124                                        'tag'                => [
    107125                                                'description'       => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    134152                                                'type'        => 'string',
    135153                                        ],
    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                                        ),
    156172                                        'menu_order_min'     => [
    157173                                                'description'       => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    182198                                                },
    183199                                        ],
    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                                        ),
    194208                                        'manage_stock'       => [
    195209                                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    291305                                                'validate_callback' => [ Products_Controller::class, 'validate_orderby' ],
    292306                                        ],
    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(),
    303311                                        'delimiter'          => [
    304312                                                'description'       => __( 'CSV field delimiter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    460468                // order, which the client already builds from the grid's display order (#225).
    461469                $orderby = (string) $request->get_param( 'orderby' );
    462                 $order   = (string) $request->get_param( 'order' );
     470                $order   = Products_Controller::resolve_sort_direction( $request );
    463471
    464472                $units = [
    … …  
    513521
    514522        /**
    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
    518528         * exit(), so it cannot run under PHPUnit — a test subclass exposes this
    519529         * method instead, which is what keeps the filter→CSV bridge covered.
    … …  
    523533         */
    524534        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 );
    696536        }
    697537
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/REST/Products_Controller.php

    r3700717 r3736631  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Html_Loss_Detector;
    1011use WCABE_G2\Helpers\Page_Size_Helper;
    1112use WCABE_G2\Helpers\Permission_Helper;
    … …  
    2627        const BATCH_LIMIT = 100;
    2728
     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
    2838        protected $namespace = 'wcabe/g2';
    2939        protected $rest_base = 'products';
    … …  
    4454
    4555        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
    4659                register_rest_route( $this->namespace, '/' . $this->rest_base, [
    4760                        [
    … …  
    4962                                'callback'            => [ $this, 'get_items' ],
    5063                                '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                                ],
    5296                        ],
    5397                ] );
    … …  
    194238
    195239        /**
     240         * Preview what saving this description HTML would store (#248).
     241         *
    196242         * @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 ) {
    200267                $this->raise_resource_limits();
    201268
    … …  
    206273                $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) );
    207274                $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 {
    210357                $filters = [];
    211358
    … …  
    240387                        // Do not apply sanitize_text_field to id_operator — it HTML-encodes
    241388                        // `<` 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 '='.
    243392                        $filters['id_operator'] = $request->get_param( 'id_operator' ) ?: '=';
    244393                        $filters['id_value']    = sanitize_text_field( $id_value );
    … …  
    263412                $category = $request->get_param( 'category' );
    264413                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';
    267417                }
    268418
    … …  
    395545                 * @param \WP_REST_Request $request Incoming request.
    396546                 */
    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 );
    413548        }
    414549
    … …  
    510645         * purpose — see the arg definition in get_collection_params().
    511646         *
     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         *
    512656         * @param mixed            $value   Raw request value.
    513657         * @param \WP_REST_Request $request Incoming request.
    … …  
    516660         */
    517661        public static function validate_orderby( $value, \WP_REST_Request $request, string $param ) {
     662                if ( ! Permission_Helper::can_manage() ) {
     663                        return true;
     664                }
     665
    518666                $columns = Product_Service::get_sortable_columns();
    519667                if ( is_string( $value ) && in_array( $value, $columns, true ) ) {
    … …  
    529677                        ),
    530678                        [ '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' ] )
    531848                );
    532849        }
    … …  
    578895                                'validate_callback' => [ self::class, 'validate_orderby' ],
    579896                        ],
    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(),
    586900                        'search_name' => [
    587901                                'description'       => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    619933                                'sanitize_callback' => 'sanitize_text_field',
    620934                        ],
    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(),
    633936                        'id_value' => [
    634937                                'description'       => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    655958                                'sanitize_callback' => 'sanitize_text_field',
    656959                        ],
     960                        'category_operator' => self::category_operator_arg(),
    657961                        'tag' => [
    658962                                'description'       => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    685989                                'type'        => 'string',
    686990                        ],
    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                        ),
    7711015                        'menu_order_min' => [
    7721016                                '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  
    7777                                'enum'    => [ 'adaptive', '25', '50', '100' ],
    7878                        ],
     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                        ],
    7986                ];
    8087        }
    … …  
    204211                }
    205212
    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 ) );
    207217
    208218                // 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  
    2727                                'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ],
    2828                                'args'                => [
    29                                         'id' => [
     29                                        'id'      => [
    3030                                                'description'       => __( 'Product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    3131                                                'type'              => 'integer',
    … …  
    3333                                                'sanitize_callback' => 'absint',
    3434                                        ],
     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(),
    3549                                ],
    3650                        ],
    … …  
    4862
    4963                $product_id = absint( $request->get_param( 'id' ) );
     64                $orderby    = (string) $request->get_param( 'orderby' );
     65                $order      = Products_Controller::resolve_sort_direction( $request );
    5066
    5167                $service = new Product_Service();
    52                 $result = $service->get_variations( $product_id );
     68                $result  = $service->get_variations( $product_id, $orderby, $order );
    5369
    5470                if ( is_wp_error( $result ) ) {
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Attribute_Service.php

    r3549389 r3736631  
    1919        private static $schema_cache = null;
    2020
     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
    2145        // TODO: Custom per-product text attributes (non-taxonomy) are not supported.
    2246        // Global taxonomy-backed attributes only. Per-product attributes are a different
    … …  
    2751         *
    2852         * 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.
    3057         *
    3158         * @return array Array of [ 'slug' => 'color', 'label' => 'Color', 'taxonomy' => 'pa_color', 'hierarchical' => false, 'orderby' => 'menu_order' ].
    … …  
    4269                }
    4370
     71                $unsafe = self::attribute_registry_hazards()['unsafe'];
    4472                $result = [];
    4573
    4674                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 );
    4982
    5083                        $result[] = [
    … …  
    141174
    142175        /**
    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).
    144405         */
    145406        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;
    147411        }
    148412}
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Export_Service.php

    r3700717 r3736631  
    7272                'slug'               => 'Slug',
    7373                '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',
    7476                'type'               => 'Type',
    7577                'status'             => 'Status',
    … …  
    256258                $dimension_unit = $units['dimension_unit'] ?? '';
    257259
     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
    258272                $headers = [];
    259273                foreach ( $columns as $col ) {
    … …  
    268282                        } elseif ( strncmp( $col, 'attr_pa_', 8 ) === 0 ) {
    269283                                $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                                );
    272289                        } elseif ( isset( $this->pro_columns[ $col ] ) ) {
    273290                                $label = $this->pro_columns[ $col ];
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Operations_Service.php

    r3663491 r3736631  
    3232         */
    3333        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;
    3467
    3568        /**
    … …  
    74107         * see the phase-2 comment. Count creations off created_ids; render rows off products.
    75108         *
     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         *
    76125         * @param int[] $product_ids Source product IDs.
    77126         * @param int   $count       Number of copies to create per source product.
    … …  
    87136                $failed             = [];
    88137                $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 = [];
    89150
    90151                if ( empty( $product_ids ) ) {
    … …  
    116177                        }
    117178
     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
    118193                        for ( $i = 1; $i <= $count; $i++ ) {
    119194                                $suffix    = ( 1 === $i ) ? ' (Copy)' : sprintf( ' (Copy %d)', $i );
    … …  
    121196                                $duplicate->set_id( 0 );
    122197                                $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 );
    144261                                }
    145262
     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
    146272                                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.
    149287                                        // 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.
    150288                                        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 {
    161301                                        $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;
    176307                                }
    177308                        }
    … …  
    240371                }
    241372
     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
    242386                if ( $format_failures > 0 ) {
    243387                        // Appended, never replacing — a run can hit both a variation failure and a format
    … …  
    258402
    259403        /**
     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        /**
    260438         * Duplicate all variations from source to new parent.
    261439         *
    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 ) {
    275462                        $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 ) {
    280494                        $new_variation = clone $child;
    281495                        $new_variation->set_id( 0 );
    282496                        $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 );
    285506                        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;
    294970        }
    295971
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/Product_Service.php

    r3700717 r3736631  
    99
    1010use WCABE_G2\Handlers\WP_Save_Hooks_Handler;
     11use WCABE_G2\Helpers\Html_Loss_Detector;
    1112use WCABE_G2\Helpers\Product_Helper;
     13use WCABE_G2\Helpers\Term_Helper;
    1214
    1315defined( 'ABSPATH' ) || exit;
    … …  
    1719        /** Allowed Title-search match modes. */
    1820        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' ];
    1924
    2025        /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */
    … …  
    133138
    134139        /**
     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 `&nbsp;`-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        /**
    135164         * The installed core's own UTF-8 validity check.
    136165         *
    … …  
    154183
    155184        /**
    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 . '&amp;'` 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).
    157196         *
    158197         * Pure predicate. There is no way to ask WordPress: sanitize_title() has no
    … …  
    180219         * so a late registration runs last among the <= 9 callbacks — immediately
    181220         * before sanitize_title_with_dashes()), calls sanitize_title() exactly as the
    182          * save will, and has the observer measure the UNCAPPED encoding of the
     221         * save will, and has the observer test the tail the cap cuts off the
    183222         * string it receives. The last invocation wins: a re-entrant filter like
    184223         * WPML's fires the observer again with the expanded title, and that nested
    … …  
    188227         * stages — strip_tags(), the percent-octet dance (which also rewrites
    189228         * 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.
    196236         *
    197237         * Invalid UTF-8 mirrors core: no encoding, no cap, so never "too long" here.
    … …  
    205245                $exceeds  = false;
    206246                $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 );
    209249                        return $title;
    210250                };
    … …  
    221261
    222262        /**
    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().
    227343         *
    228344         * @param string $title Title as received by the priority-10 callback.
    229          * @return int|null
    230          */
    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 {
    232348                $title = strip_tags( $title ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- mirroring sanitize_title_with_dashes() byte-for-byte.
    233349                // Preserve escaped octets, drop lone percent signs, restore octets.
    … …  
    243359                }
    244360
    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 ) );
    246365        }
    247366
    … …  
    254373
    255374        /**
    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;
    273410
    274411        /**
    … …  
    284421        private const QUERY_MODE_IDS              = 'ids';
    285422        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})?$'";
    286448
    287449        /**
    … …  
    307469         */
    308470        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',
    310472                'cogs_value',
    311473                'date_created',
    … …  
    325487        private const ALLOWED_FIELDS = [
    326488                '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',
    328490                        'cogs_value',
    329491                        'date_created',
    … …  
    338500                ],
    339501                'variable' => [
    340                         'name', 'sku', 'slug', 'status', 'catalog_visibility',
     502                        'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    341503                        'cogs_value',
    342504                        'date_created',
    … …  
    350512                ],
    351513                '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',
    353515                        'cogs_value',
    354516                        'description',
    … …  
    361523                ],
    362524                'grouped'  => [
    363                         'name', 'sku', 'slug', 'status', 'catalog_visibility',
     525                        'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    364526                        'date_created',
    365527                        'categories', 'tags', 'description', 'short_description',
    … …  
    374536                ],
    375537                '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',
    377539                        'cogs_value',
    378540                        'date_created',
    … …  
    408570         */
    409571        private const DEFAULT_ALLOWED_FIELDS = [
    410                 'name', 'sku', 'slug', 'status', 'catalog_visibility',
     572                'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    411573                'date_created',
    412574                'categories', 'tags', 'description', 'short_description',
    … …  
    506668                string $order = 'desc'
    507669        ): 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                }
    537745        }
    538746
    … …  
    550758         * @param bool          $include_variations Interleave variations under their parents.
    551759         * @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.
    552764         * @return array[] Formatted rows.
    553765         */
    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 {
    555767                // Allow Pro to prime caches before per-product formatting.
    556768                $product_ids = array_map( fn( $p ) => $p->get_id(), $wc_products );
    … …  
    637849                        }
    638850
     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
    639856                        $variation_ids_to_prime = [];
    640857                        foreach ( $children_map as $children ) {
    … …  
    660877                        }
    661878
    662                         $formatted = $this->interleave_variations( $formatted, $filters, $children_map );
     879                        $formatted = $this->interleave_variations( $formatted, $filters, $children_map, $child_sort );
    663880                }
    664881
    … …  
    672889         * @return array|\WP_Error
    673890         */
    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                }
    723951        }
    724952
    … …  
    7821010                string $order = 'desc'
    7831011        ): 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                                                }
    8091043                                        }
    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                }
    8521088        }
    8531089
    … …  
    10591295                // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered —
    10601296                // 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().
    10651299                $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string {
    10661300                        if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) {
    … …  
    13361570                                                }
    13371571
    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 ) );
    13391582                                        }
    13401583
    … …  
    13581601                                        // escape batch_update() entirely and 500 the request. Same rule, and the same
    13591602                                        // 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
    13601620                                        try {
    13611621                                                do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token );
    … …  
    17662026                                        ];
    17672027                                } else {
     2028                                        $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag';
    17682029                                        foreach ( $value as $term_data ) {
    17692030                                                if ( ! is_array( $term_data ) || ( ! isset( $term_data['id'] ) && ! isset( $term_data['name'] ) ) ) {
    … …  
    17742035                                                        ];
    17752036                                                        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;
    17762047                                                }
    17772048                                        }
    … …  
    18402111                                                        __( 'SKU "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    18412112                                                        $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
    18422141                                                ),
    18432142                                        ];
    … …  
    19952294         * @param mixed       $value
    19962295         * @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().
    19972299         * @throws \InvalidArgumentException If validation fails.
    19982300         */
    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 {
    20002302                switch ( $field ) {
    20012303                        case 'name':
    … …  
    20052307                        case 'sku':
    20062308                                $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 ) );
    20072316                                break;
    20082317
    … …  
    20742383                                // rather than clobbering it from the in-memory cogs_value prop.
    20752384                                // 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.
    20772388                                if ( Product_Helper::is_cogs_enabled() ) {
    20782389                                        $sanitized = trim( (string) $value );
    … …  
    21412452                        case 'categories':
    21422453                        case 'tags':
    2143                                 $this->apply_taxonomy_change( $product, $field, $value );
    2144                                 break;
     2454                                return $this->apply_taxonomy_change( $product, $field, $value );
    21452455
    21462456                        case 'description':
    2147                                 $product->set_description( wp_kses_post( (string) $value ) );
    2148                                 break;
    2149 
    21502457                        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                                }
    21522476                                break;
    21532477
    … …  
    22622586                                break;
    22632587                }
     2588
     2589                return [];
    22642590        }
    22652591
    … …  
    23632689         * Accepts an array of {id, name} objects.
    23642690         * - 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.
    23662693         *
    23672694         * @param \WC_Product $product
    23682695         * @param string      $field 'categories' or 'tags'.
    23692696         * @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 {
    23722701                if ( ! is_array( $value ) ) {
    2373                         return;
     2702                        return [];
    23742703                }
    23752704
    23762705                $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag';
    23772706                $term_ids = [];
     2707                $warnings = [];
    23782708
    23792709                foreach ( $value as $term_data ) {
    … …  
    23842714                        $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0;
    23852715                        $name    = isset( $term_data['name'] ) ? sanitize_text_field( (string) $term_data['name'] ) : '';
     2716                        $parent  = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0;
    23862717
    23872718                        if ( $term_id > 0 ) {
    … …  
    23892720                                if ( $term && ! is_wp_error( $term ) ) {
    23902721                                        $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 );
    23912726                                }
    23922727                        } 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;
    23972732                                } else {
    23982733                                        // Create new term.
    23992734                                        $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;
    24022737                                        }
    24032738                                        $result = wp_insert_term( $name, $taxonomy, $insert_args );
    24042739                                        if ( ! is_wp_error( $result ) ) {
    24052740                                                $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 );
    24062746                                        }
    24072747                                }
    … …  
    24142754                        $product->set_tag_ids( $term_ids );
    24152755                }
     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                );
    24162838        }
    24172839
    … …  
    24682890        ): array {
    24692891                $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;
    24702903
    24712904                // Every non-default sort is applied by build_sort_clauses() below, which
    … …  
    26153048                $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value );
    26163049
    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.
    26183052                if ( ! empty( $filters['category'] ) ) {
    26193053                        $args['category'] = array_map( 'absint', (array) $filters['category'] );
     3054                        if ( 'not_in' === ( $filters['category_operator'] ?? 'in' ) ) {
     3055                                $args['wcabe_category_operator'] = 'not_in';
     3056                        }
    26203057                }
    26213058
    … …  
    27703207                $has_sale_date_filter    = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] )
    27713208                        || 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
    27773225                        // Exact category matching — replace WC's default tax_query with include_children => false.
    27783226                        if ( ! empty( $query_vars['category'] ) ) {
    … …  
    27903238                                }
    27913239
    2792                                 $wp_query_args['tax_query'][] = [
     3240                                $category_clause = [
    27933241                                        'taxonomy'         => 'product_cat',
    27943242                                        'field'            => 'term_id',
    … …  
    27963244                                        'include_children' => false,
    27973245                                ];
    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 {
    28033266                                if ( ! empty( $wp_query_args['meta_query'] ) ) {
    28043267                                        $wp_query_args['meta_query'] = array_filter(
    … …  
    28103273                                        $wp_query_args['meta_query'] = array_values( $wp_query_args['meta_query'] );
    28113274                                }
     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 );
    28123286
    28133287                                $wp_query_args['meta_query'][] = [
    … …  
    29863460                        // Stock quantity range filtering via _stock meta.
    29873461                        // 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).
    29943467                        $has_stock_min = isset( $query_vars['wcabe_stock_qty_min'] );
    29953468                        $has_stock_max = isset( $query_vars['wcabe_stock_qty_max'] );
    … …  
    31093582                // variation with a matching _variation_description so the parent survives
    31103583                // 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.
    31113587                $description_where_callback = null;
    31123588                if ( $search_description_term ) {
    … …  
    31193595                                        // conversion must not surface its now-simple parent.
    31203596                                        $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.
    31223598                                        $where      .= $wpdb->prepare(
    31233599                                                " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN ("
    31243600                                                . " 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'
    31263602                                                . " WHERE p.post_type = 'product_variation'"
     3603                                                . " AND p.post_status IN ('publish','private')"
    31273604                                                . " AND pm.meta_key = '_variation_description'"
    31283605                                                . " AND pm.meta_value LIKE %s"
    … …  
    31313608                                                $desc_like
    31323609                                        );
    3133                                         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     3610                                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
    31343611                                } else {
    31353612                                        $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like );
    … …  
    31953672                }
    31963673
    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 allows
    3199                 // 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_max
    3226                                         );
    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_min
    3231                                         );
    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_max
    3236                                         );
    3237                                 }
    3238                                 return $where;
    3239                         };
    3240                 }
    3241 
    32423674                // 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
    32443676                // WP's meta_query cannot express "meta row missing", which the is_empty
    32453677                // operator needs. Every column is alias-qualified — an unqualified
    32463678                // post_id/meta_key/meta_value becomes ambiguous the moment another meta
    32473679                // 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;
    32513682
    32523683                if ( $has_product_url_filter ) {
    32533684                        global $wpdb;
    32543685
    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 {
    32803687                                if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) {
    3281                                         $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_meta ON ( {$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' ) ";
    32823689                                }
    32833690                                return $join;
    … …  
    32863693                        $is_external_sql = $this->sql_is_external_product();
    32873694
    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 {
    32923696
    32933697                                $col       = 'wcabe_product_url_meta.meta_value';
    … …  
    33503754
    33513755                // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date,
    3352                 //    manage stock, backorders ──
     3756                //    manage stock, backorders, stock quantity, stock status ──
    33533757                //
    3354                 // ONE callback pair covers all six deliberately. Each of them can surface a
     3758                // ONE callback pair covers all eight deliberately. Each of them can surface a
    33553759                // variable parent either on its OWN value or through a child variation, and
    33563760                // emitting them as separate `( own_i OR child_i )` clauses would let *different*
    … …  
    33703774                // single-filter behaviour is unchanged.
    33713775                //
    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.
    33783788                //
    33793789                // The child predicates themselves live in build_variation_child_predicates()
    33803790                // ('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).
    33843792                //
    33853793                // The child status list matches WC get_children() (publish/private only) so a
    … …  
    33893797                // its now-simple parent (interleaving only narrows children of variable
    33903798                // parents, so such a false positive is uncorrectable downstream). COGS, sale
    3391                 // dates, manage stock and backorders participate only when include_variations
    3392                 // is on; with it off they stay on their meta_query paths and never reach this
    3393                 // callback, so with variations off only menu order and image date are emitted
    3394                 // 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.
    33953803                //
    33963804                // Manage stock / backorders child predicates use the value WooCommerce REPORTS
    … …  
    34103818                // The OWN predicates never coalesce: a product with no row must behave the same
    34113819                // 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).
    34123831                $combined_join_callback  = null;
    34133832                $combined_where_callback = null;
    … …  
    34353854                $use_ms    = $include_variations && $has_manage_stock_filter;
    34363855                $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;
    34373858
    34383859                $manage_stock_values = $use_ms ? array_values( array_map( 'strval', (array) $args['wcabe_manage_stock'] ) ) : [];
    34393860                $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 ) {
    34423869                        global $wpdb;
    34433870
    … …  
    34673894                        );
    34683895
    3469                         // Only COGS, sale dates, image date, manage stock and backorders need
    3470                         // 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 {
    34733900                                        if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) {
    3474                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_meta ON ( {$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' ) ";
    34753902                                        }
    34763903
    3477                                         // The parent's manage flag is needed for BOTH filters: backorders
    3478                                         // 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' ) ";
    34813908                                        }
    34823909
     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
    34833917                                        if ( $use_bk && strpos( $join, 'wcabe_bk_meta' ) === false ) {
    3484                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_bk_meta ON ( {$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' ) ";
    34853919                                        }
    34863920
    34873921                                        foreach ( $sale_predicates as $range ) {
    34883922                                                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']}' ) ";
    34903924                                                }
    34913925                                        }
    … …  
    34993933                                                // so a _thumbnail_id pointing at a non-attachment is excluded here
    35003934                                                // and renders blank there.
    3501                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_meta ON ( {$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' ) ";
    35023936                                                $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' ) ";
    35033937                                        }
    … …  
    35123946                        // spec keyed on $args, not $filters — a wcabe_g2_get_products_args hook
    35133947                        // may legitimately change which PARENTS surface (#218 keeps that intact).
    3514                         // stock_status / stock_qty are deliberately absent: they surface parents
    3515                         // through their own mechanisms (see the SCOPE note) and only the
    3516                         // candidate query (narrow_children_by_sql()) narrows children by them.
    35173948                        // The builder reads every axis through `! empty( $spec[...] )`, so an
    35183949                        // 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
    35193956                        $surface_spec = [
    35203957                                'cogs'         => $use_cogs ? [ 'min' => $cogs_min, 'max' => $cogs_max ] : null,
    … …  
    35243961                                'manage_stock' => $use_ms ? $manage_stock_values : null,
    35253962                                '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,
    35263965                                // 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',
    35333974                        ];
    35343975
    … …  
    35423983                                $use_ms,
    35433984                                $use_bk,
     3985                                $use_qty,
     3986                                $use_ss,
    35443987                                $sale_predicates,
    35453988                                $cogs_min,
    … …  
    35513994                                $manage_stock_values,
    35523995                                $backorders_values,
     3996                                $stock_qty_min,
     3997                                $stock_qty_max,
     3998                                $stock_status_values,
     3999                                $ss_ids,
    35534000                                $surface_spec
    35544001                        ): string {
    … …  
    35954042                                }
    35964043
     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
    35974062                                $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )';
    35984063
    … …  
    36014066                                        $fragment = $this->build_variation_child_predicates( $surface_spec, 'surface' );
    36024067                                        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']}"
    36044072                                                        . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'"
    36054073                                                        . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $fragment['parts'] ) . ' ) )';
    … …  
    36264094                // (order_by_price_{asc,desc}_post_clauses in includes/class-wc-query.php).
    36274095                $sort_clauses_callback = null;
    3628                 $sort_marker_callback  = null;
    36294096
    36304097                $sort_spec = ( 'id' !== $orderby && ! $ids_only_all_variable )
    … …  
    36334100
    36344101                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 would
    3638                         // silently reorder (and join a foreign table into) a nested query. Both
    3639                         // callbacks below bail unless the query carries our private marker. The
    3640                         // marker filter itself also fires for every wc_get_products() call while
    3641                         // registered, so it only stamps queries whose ORIGINATING query vars carry
    3642                         // our sentinel (set on $args here; WC_Object_Query keeps unknown args via
    3643                         // 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 the
    3645                         // 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 
    36564102                        $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 );
    36574176                }
    36584177
    … …  
    36664185                }
    36674186
    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 );
    37084189                }
    37094190
    37104191                try {
     4192                        ++self::$products_query_depth;
    37114193                        $results = wc_get_products( $args );
    37124194                } 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                        }
    37134200                        if ( $needs_custom_query ) {
    37144201                                remove_filter(
    … …  
    37184205                                );
    37194206                        }
    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                        );
    37544220                }
    37554221
    … …  
    39244390
    39254391                $child_join = $negated
    3926                         ? "LEFT JOIN {$wpdb->postmeta} pm ON ( 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';
    39284394                $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'";
    39294395
    … …  
    40634529
    40644530        /**
     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        /**
    40654724         * LEFT JOIN fragment resolving ONE postmeta row per product for a meta key,
    40664725         * under alias `$alias` (a wp_postmeta row: `$alias.meta_value`).
    … …  
    40814740         * @return string
    40824741         */
    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 {
    40844743                $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} )"
    40884754                        . " 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;
    40894855        }
    40904856
    … …  
    41004866         * @return string
    41014867         */
    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 {
    41034869                $taxonomy = esc_sql( $taxonomy );
     4870                $on       = '' === $on ? "{$wpdb->posts}.ID" : $on;
     4871                $restrict = self::sql_id_restriction( 'tr.object_id', $ids );
    41044872
    41054873                return " LEFT JOIN ( SELECT tr.object_id, MIN( t.slug ) AS slug, MIN( t.name ) AS name"
    … …  
    41074875                        . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id"
    41084876                        . " 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} ) ";
    41104878        }
    41114879
    … …  
    41184886         * @return string
    41194887         */
    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
    41214892                return " LEFT JOIN ( SELECT tr.object_id,"
    41224893                        . " MAX( t.slug = 'featured' ) AS featured,"
    … …  
    41264897                        . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id"
    41274898                        . " 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} ) ";
    41294900        }
    41304901
    … …  
    41374908        private const SORT_TYPE_ALIAS = 'wcabe_sort_ptype';
    41384909
    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 ];
    41414932        }
    41424933
    … …  
    41944985         *           (variable parents carry one `_price` row per child) nor from the
    41954986         *           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).
    41965029         *
    41975030         * Blank rows sort LAST in both directions, mirroring the grid's client-side
    … …  
    42255058                $specs = [
    42265059                        // 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' ],
    42345076
    42355077                        // wc_product_meta_lookup columns (one row per product, PK product_id).
    … …  
    42375079                        // are both SKU-less; lexical ordering under the table collation (typically
    42385080                        // *_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' ],
    42405088                        // stock_quantity is NULL when stock is not managed — blank, matching the
    42415089                        // "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' ],
    42465102                        // Price — mirrors WooCommerce core's order_by_price_{asc,desc}_post_clauses().
    42475103                        // Ascending ranks a variable parent by its cheapest child (min_price),
    … …  
    42515107                        // NULL. EXISTS is multi-row safe (variable parents carry one `_price` row
    42525108                        // 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).
    42535116                        '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                                },
    42635134                        ],
    42645135
    … …  
    42685139                        // _sale_price of their own (a stale one can survive a type change), so
    42695140                        // 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' ] ],
    42755150                        // Sale dates are stored as UNIX timestamps; format_product() blanks them
    42765151                        // 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' ],
    42795154
    42805155                        // postmeta strings. Both read as 'no' when the row is missing (WC's
    42815156                        // getters default; format_product() renders an unchecked box / "Do not
    42825157                        // 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' ],
    42855163                        // 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' ],
    42885166
    42895167                        // Taxonomy-backed and derived columns.
    … …  
    42935171                                'key'   => static fn( \wpdb $wpdb ): string => self::sql_product_type_expr(),
    42945172                                'type'  => 'string',
     5173                                'child' => 'none',
    42955174                        ],
    42965175                        // 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).
    42975178                        'shipping_class'     => [
    42985179                                '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                                },
    43005184                                'key'   => static fn( \wpdb $wpdb ): string => 'wcabe_sort_ship.name',
    43015185                                'type'  => 'string',
     5186                                'child' => 'same',
    43025187                        ],
    43035188                        // 0/1 flag; a product with no visibility term at all is simply not featured.
    … …  
    43075192                                'key'   => static fn( \wpdb $wpdb ): string => 'COALESCE( wcabe_sort_vis.featured, 0 )',
    43085193                                'type'  => 'numeric_native',
     5194                                'child' => 'none',
    43095195                        ],
    43105196                        // WC encodes visibility as two exclusion terms: both → hidden,
    … …  
    43185204                                        . " ELSE 'visible' END )",
    43195205                                'type'  => 'string',
     5206                                'child' => 'none',
    43205207                        ],
    43215208                        // Featured image upload date: the attachment's post_date. The post_type
    … …  
    43255212                        'image_date'         => [
    43265213                                '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                                },
    43295219                                'key'   => static fn( \wpdb $wpdb ): string => 'wcabe_sort_img.post_date',
    43305220                                'type'  => 'string',
     5221                                'child' => 'same',
    43315222                        ],
    43325223                ];
    … …  
    43355226                // render the column otherwise, and the REST enum rejects the key.
    43365227                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' ];
    43385229                }
    43395230
    … …  
    43625253        }
    43635254
    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' ];
    43675261
    43685262        /**
    … …  
    44115305                        }
    44125306                }
     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                }
    44135335                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} ) )";
    44145431        }
    44155432
    … …  
    44215438        public static function get_sortable_columns(): array {
    44225439                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                };
    44235460        }
    44245461
    … …  
    44425479         * product_type join with the `type` spec).
    44435480         *
     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         *
    44445484         * @param array<string, mixed> $spec One get_sort_specs() entry.
    44455485         * @param bool                 $asc  Sort direction.
    4446          * @return callable( array, \WP_Query ): array
     5486         * @return callable( array ): array
    44475487         */
    44485488        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 {
    44505490                        global $wpdb;
    44515491
    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 )";
    45235536                        }
    45245537
    … …  
    45365549                        return $clauses;
    45375550                };
     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                ];
    45385820        }
    45395821
    … …  
    46805962         */
    46815963        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';
    46836056        }
    46846057
    … …  
    47666139         *                  and the caller has already widened the bounds.
    47676140         *
    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
    47726146         * query does not re-join the same parent rows once per child row scanned.
    47736147         * When absent (the standalone candidate query has no outer row) the fragment
    … …  
    47836157         *                         null (never matches).
    47846158         *
     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         *
    47856168         * @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).
    47896173         * @param string $mode 'surface' or 'candidate'.
    47906174         * @return array{joins: string, parts: string[]}
    … …  
    48086192                // surfaces on a child the narrowing then drops.
    48096193                $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 );
    48136195                };
    48146196
    48156197                if ( ! empty( $spec['cogs'] ) ) {
    48166198                        $col     = 'wcabe_child_cogs.meta_value';
    4817                         $joins  .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogs ON ( 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' ) ";
    48186200                        $bound   = $candidate
    48196201                                ? self::sql_decimal_range_or_keep( $wpdb, $col, $spec['cogs']['min'], $spec['cogs']['max'] )
    … …  
    48306212                        // non-attachment means no row — the same "no match" PHP reaches through
    48316213                        // Product_Helper::get_image_upload_date().
    4832                         $joins  .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_meta ON ( 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' ) ";
    48336215                        $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' ) ";
    48346216                        $parts[] = self::sql_bound( $wpdb, 'wcabe_child_img.post_date', $spec['image']['min'], $spec['image']['max'], '%s' );
    … …  
    48376219                foreach ( (array) ( $spec['sale'] ?? [] ) as $range ) {
    48386220                        $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']}' ) ";
    48406222                        $bound   = $candidate
    48416223                                ? self::sql_timestamp_range_or_keep( $wpdb, $col, $range['min'], $range['max'] )
    … …  
    48506232                        // _backorders / _stock row must still exist for the inheritance branch,
    48516233                        // where the parent's value is used instead.
    4852                         $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_ms ON ( 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' ) ";
    48536235
    48546236                        $parent_ms_col = $spec['parent_ms_col'] ?? null;
    48556237                        if ( null === $parent_ms_col ) {
    4856                                 $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pms ON ( 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' ) ";
    48576239                                $parent_ms_col = 'wcabe_child_pms.meta_value';
    48586240                        }
    … …  
    48696251
    48706252                        if ( ! empty( $spec['backorders'] ) ) {
    4871                                 $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_bk ON ( 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' ) ";
    48726254                                $parent_bk_col = $spec['parent_bk_col'] ?? null;
    48736255                                if ( null === $parent_bk_col ) {
    4874                                         $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pbk ON ( 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' ) ";
    48756257                                        $parent_bk_col = 'wcabe_child_pbk.meta_value';
    48766258                                }
    … …  
    48796261
    48806262                        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} )";
    48906283                        }
    48916284                }
    … …  
    48966289                        // when 'instock' is selected, every such row must be KEPT; otherwise it
    48976290                        // 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.
    48986295                        $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() ) . ' )'
    49046301                                : $in;
    49056302                }
    … …  
    49096306                        'parts' => $parts,
    49106307                ];
     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;
    49116430        }
    49126431
    … …  
    50276546
    50286547                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;
    50296826        }
    50306827
    … …  
    51236920                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    51246921
    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 ) {
    51376924                                foreach ( $chunk as $child_id ) {
    51386925                                        foreach ( $child_parents[ $child_id ] as $parent_id ) {
    … …  
    51436930                        }
    51446931
    5145                         foreach ( (array) $wpdb->last_result as $row ) {
     6932                        foreach ( $rows as $row ) {
    51466933                                $survivor_set[ (int) $row->ID ] = true;
    51476934                        }
    … …  
    51826969        // TODO: Phase 5 — consider capping variations per product when include_variations is on,
    51836970        // 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 {
    51856972                $result          = [];
    51866973                $has_meta_filter = $this->has_variation_meta_filters( $filters );
    … …  
    51896976                $attribute_slugs = $this->resolve_attribute_filter_slugs( $filters['attribute_filter'] ?? [] );
    51906977
    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 ) {
    51946983                        // variation_count is only non-zero for variable-capable products
    51956984                        // (format_product gates it on instanceof WC_Product_Variable), so it
    … …  
    52417030                                }
    52427031
     7032                                $rows = [];
    52437033                                foreach ( $children as $child_id ) {
    52447034                                        $variation = wc_get_product( $child_id );
    … …  
    52477037                                                        continue;
    52487038                                                }
    5249                                                 $result[] = Product_Helper::format_variation( $variation );
     7039                                                $rows[] = Product_Helper::format_variation( $variation );
    52507040                                        }
    52517041                                }
     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;
    52527054                        }
    52537055                }
    … …  
    52567058        }
    52577059
    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,
    52717067         * 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 {
    52897078                global $wpdb;
    52907079                $variation_count = 0;
    … …  
    53017090                        // renders those. Counting draft/pending here would over-report rows
    53027091                        // that never display. Parent status filtering is already applied via
    5303                         // the parent-ID query above; this clause is the independent
     7092                        // get_matching_variable_parent_ids(); this clause is the independent
    53047093                        // VARIATION-status axis in WC's data model.
    53057094                        // 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.
    … …  
    53167105                }
    53177106
    5318                 return [
    5319                         'total'          => $total_parents + $variation_count,
    5320                         'is_approximate' => $this->has_variation_meta_filters( $filters ),
    5321                 ];
     7107                return $variation_count;
    53227108        }
    53237109
    … …  
    57577543                                continue;
    57587544                        }
    5759                         $resolved[ sanitize_title( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );
     7545                        $resolved[ Attribute_Service::attribute_key( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );
    57607546                }
    57617547                return $resolved;
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/includes/Services/View_Service.php

    r3663491 r3736631  
    140140                }
    141141
    142                 update_user_meta( $user_id, 'wcabe_g2_views', $views );
     142                $this->persist( $user_id, $views );
    143143
    144144                return $view;
    … …  
    203203                // create_view sanitize on the touched view is sufficient since other
    204204                // 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 );
    206206
    207207                return $views[ $index ];
    … …  
    241241                // create_view sanitize on the touched view is sufficient since other
    242242                // 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 );
    244244
    245245                return true;
    … …  
    374374                }
    375375
     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
    376386                // Product URL filter. Canonicalized here as well as in the client store,
    377387                // because a view can also be created/imported straight through the REST
    … …  
    396406                        'search_sku'              => $search_sku,
    397407                        'search_sku_mode'         => $search_sku_mode,
    398                         'category'                => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ),
     408                        'category'                => $category,
     409                        'category_operator'       => $category_operator,
    399410                        'tag'                     => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ),
    400411                        'type'                    => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ),
    … …  
    520531
    521532        /**
     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        /**
    522549         * Ensure only the given view ID is marked as default.
    523550         *
  • wpmelon-advanced-bulk-edit-for-woocommerce/tags/1.4.0/readme.txt

    r3700717 r3736631  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.3.0
     7Stable tag: 1.4.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3131* **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style
    3232* **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 more
     33* **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
    3434* **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 more
     35* **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
    3636* **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 end
     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, empty values stay at the end, and variable products rank by their variations
    3838* **Keyboard shortcuts** — navigate and edit without touching the mouse; arrow keys move between cells even while you are typing
    3939* **Product operations** — duplicate and change status right from the grid
    … …  
    5454= Pro version =
    5555
    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/).
     56If 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/).
    5757
    5858The 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.
    … …  
    134134
    135135== 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 `&nbsp;`.
     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.
    136156
    137157= 1.3.0 =
    … …  
    217237== Upgrade Notice ==
    218238
     239= 1.4.0 =
     240Visual description editor, GTIN column, Category "Is none of" filter, variation-aware sorting, WooCommerce-style duplicates, page-cache fixes, and many fixes.
     241
    219242= 1.3.0 =
    220243Whole-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  
    44 * Plugin URI: https://wpmelon.com/bulk-edit/
    55 * Description: Excel-like bulk editing interface for WooCommerce products
    6  * Version: 1.3.0
     6 * Version: 1.4.0
    77 * Author: WPMelon
    88 * Author URI: https://wpmelon.com/
    … …  
    1414 * Requires Plugins: woocommerce
    1515 * WC requires at least: 10.0
    16  * WC tested up to: 11.1.0
     16 * WC tested up to: 11.2.0
    1717 * License: GPLv2 or later
    1818 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    126126define( 'WCABE_G2_LOADED', true );
    127127
    128 define( 'WCABE_G2_VERSION', '1.3.0' );
     128define( 'WCABE_G2_VERSION', '1.4.0' );
    129129define( 'WCABE_G2_PLUGIN_FILE', __FILE__ );
    130130define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Admin.php

    r3700717 r3736631  
    8585                        'isPro'             => defined( 'WCABE_G2_PRO' ) && WCABE_G2_PRO,
    8686                        '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' ),
    8791                        // Whether this site's woocommerce_stock_amount filter truncates a
    8892                        // decimal stock quantity to a whole number (true on a default
    … …  
    150154
    151155        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';
    153157
    154158                if ( ! file_exists( $manifest_path ) ) {
    … …  
    181185                }
    182186
    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.
    184193                wp_enqueue_script(
    185194                        'wcabe-g2-admin',
    186195                        $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,
    189198                        true
    190199                );
     200                // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion,WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion
    191201
    192202                wp_set_script_translations(
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Handler.php

    r3663491 r3736631  
    3535                add_filter( 'wcabe_g2_filter_options', [ $this, 'inject_attribute_terms' ] );
    3636                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' ] );
    3740        }
    3841
    … …  
    141144                        $data_key = 'attr_' . $taxonomy;
    142145
    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 ) ) {
    144150                                $data[ $data_key ] = [
    145151                                        'terms'     => [],
    … …  
    150156                        }
    151157
    152                         $wc_attr   = $attributes[ $taxonomy ];
     158                        $wc_attr   = $attributes[ $map_key ];
    153159                        $term_ids  = $wc_attr->get_options();
    154160                        $terms     = [];
    … …  
    192198         *
    193199         * 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
    195204         * means "Any" — the variation applies to all terms of that attribute.
    196205         *
    … …  
    212221                        $taxonomy = $attr['taxonomy'];
    213222                        $data_key = 'attr_' . $taxonomy;
    214                         $slug     = $var_attrs[ $taxonomy ] ?? '';
     223                        $slug     = $var_attrs[ Attribute_Service::attribute_key( $taxonomy ) ] ?? '';
    215224
    216225                        if ( '' === $slug ) {
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Handlers/Attribute_Save_Handler.php

    r3663491 r3736631  
    108108                }
    109109
     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
    110132                $lookup = self::get_field_lookup();
    111133
    … …  
    196218                        $attr_def  = $lookup[ $data_key ];
    197219                        $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 );
    198222                        $terms     = $value['terms'];
    199223                        $visible   = isset( $value['visible'] ) ? (bool) $value['visible'] : true;
    … …  
    205229                        if ( empty( $term_ids ) ) {
    206230                                // No terms — remove the attribute from the product.
    207                                 unset( $attributes[ $taxonomy ] );
     231                                unset( $attributes[ $map_key ] );
    208232                                $modified = true;
    209233                                continue;
    … …  
    213237                        // Clone existing attributes so WC's set_prop() detects the change
    214238                        // (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 ];
    217241                        } else {
    218242                                $wc_attr = new \WC_Product_Attribute();
    … …  
    234258                        $wc_attr->set_variation( $variation );
    235259
    236                         $attributes[ $taxonomy ] = $wc_attr;
    237                         $modified = true;
     260                        $attributes[ $map_key ] = $wc_attr;
     261                        $modified               = true;
    238262                }
    239263
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Helpers/Product_Helper.php

    r3700717 r3736631  
    77
    88namespace WCABE_G2\Helpers;
     9
     10use WCABE_G2\Services\Attribute_Service;
    911
    1012defined( 'ABSPATH' ) || exit;
    … …  
    264266                        'slug'                   => urldecode( $product->get_slug() ),
    265267                        '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' ),
    266271                        'type'                   => $product->get_type(),
    267272                        'status'                 => $product->get_status(),
    … …  
    407412                ksort( $attrs );
    408413                $term_names = [];
    409                 foreach ( $attrs as $taxonomy => $slug ) {
     414                foreach ( $attrs as $key => $slug ) {
    410415                        if ( '' === $slug ) {
    411416                                continue; // Skip "Any" attributes.
    412417                        }
    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;
    414422                        if ( $term && ! is_wp_error( $term ) ) {
    415423                                $term_names[] = $term->name;
    … …  
    694702                        // urldecode(): see format_product() — readable form of a percent-encoded slug.
    695703                        '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' ),
    697713                        'type'                   => 'variation',
    698714                        'status'                 => $variation->get_status(),
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Main.php

    r3663491 r3736631  
    4242                new Handlers\Debug_Trace_Handler();
    4343
     44                // Page-cache opt-out — keeps cache plugins from serving stale REST responses.
     45                new Handlers\Rest_Page_Cache_Handler();
     46
    4447                // WordPress save-hook compatibility — Free feature, controlled by plugin setting.
    4548                new Handlers\WP_Save_Hooks_Handler();
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Export_Controller.php

    r3700717 r3736631  
    1111use WCABE_G2\Helpers\Product_Helper;
    1212use WCABE_G2\Services\Export_Service;
    13 use WCABE_G2\Services\Filter_Service;
    1413use WCABE_G2\Services\Product_Service;
    1514use WCABE_G2\Services\View_Service;
    … …  
    8685                                                'sanitize_callback' => 'sanitize_text_field',
    8786                                        ],
     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                                        ],
    88105                                        'product_url_operator' => [
    89106                                                'description'       => __( 'Product URL filter operator.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    104121                                                'sanitize_callback' => 'sanitize_text_field',
    105122                                        ],
     123                                        'category_operator'  => Products_Controller::category_operator_arg(),
    106124                                        'tag'                => [
    107125                                                'description'       => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    134152                                                'type'        => 'string',
    135153                                        ],
    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                                        ),
    156172                                        'menu_order_min'     => [
    157173                                                'description'       => __( 'Minimum menu order filter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    182198                                                },
    183199                                        ],
    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                                        ),
    194208                                        'manage_stock'       => [
    195209                                                'description'       => __( 'Comma-separated manage stock values (yes, no).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    291305                                                'validate_callback' => [ Products_Controller::class, 'validate_orderby' ],
    292306                                        ],
    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(),
    303311                                        'delimiter'          => [
    304312                                                'description'       => __( 'CSV field delimiter.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    460468                // order, which the client already builds from the grid's display order (#225).
    461469                $orderby = (string) $request->get_param( 'orderby' );
    462                 $order   = (string) $request->get_param( 'order' );
     470                $order   = Products_Controller::resolve_sort_direction( $request );
    463471
    464472                $units = [
    … …  
    513521
    514522        /**
    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
    518528         * exit(), so it cannot run under PHPUnit — a test subclass exposes this
    519529         * method instead, which is what keeps the filter→CSV bridge covered.
    … …  
    523533         */
    524534        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 );
    696536        }
    697537
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/REST/Products_Controller.php

    r3700717 r3736631  
    88namespace WCABE_G2\REST;
    99
     10use WCABE_G2\Helpers\Html_Loss_Detector;
    1011use WCABE_G2\Helpers\Page_Size_Helper;
    1112use WCABE_G2\Helpers\Permission_Helper;
    … …  
    2627        const BATCH_LIMIT = 100;
    2728
     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
    2838        protected $namespace = 'wcabe/g2';
    2939        protected $rest_base = 'products';
    … …  
    4454
    4555        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
    4659                register_rest_route( $this->namespace, '/' . $this->rest_base, [
    4760                        [
    … …  
    4962                                'callback'            => [ $this, 'get_items' ],
    5063                                '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                                ],
    5296                        ],
    5397                ] );
    … …  
    194238
    195239        /**
     240         * Preview what saving this description HTML would store (#248).
     241         *
    196242         * @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 ) {
    200267                $this->raise_resource_limits();
    201268
    … …  
    206273                $per_page = Page_Size_Helper::clamp( $request->get_param( 'per_page' ) );
    207274                $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 {
    210357                $filters = [];
    211358
    … …  
    240387                        // Do not apply sanitize_text_field to id_operator — it HTML-encodes
    241388                        // `<` 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 '='.
    243392                        $filters['id_operator'] = $request->get_param( 'id_operator' ) ?: '=';
    244393                        $filters['id_value']    = sanitize_text_field( $id_value );
    … …  
    263412                $category = $request->get_param( 'category' );
    264413                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';
    267417                }
    268418
    … …  
    395545                 * @param \WP_REST_Request $request Incoming request.
    396546                 */
    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 );
    413548        }
    414549
    … …  
    510645         * purpose — see the arg definition in get_collection_params().
    511646         *
     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         *
    512656         * @param mixed            $value   Raw request value.
    513657         * @param \WP_REST_Request $request Incoming request.
    … …  
    516660         */
    517661        public static function validate_orderby( $value, \WP_REST_Request $request, string $param ) {
     662                if ( ! Permission_Helper::can_manage() ) {
     663                        return true;
     664                }
     665
    518666                $columns = Product_Service::get_sortable_columns();
    519667                if ( is_string( $value ) && in_array( $value, $columns, true ) ) {
    … …  
    529677                        ),
    530678                        [ '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' ] )
    531848                );
    532849        }
    … …  
    578895                                'validate_callback' => [ self::class, 'validate_orderby' ],
    579896                        ],
    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(),
    586900                        'search_name' => [
    587901                                'description'       => __( 'Search by product name.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    619933                                'sanitize_callback' => 'sanitize_text_field',
    620934                        ],
    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(),
    633936                        'id_value' => [
    634937                                'description'       => __( 'ID filter value. Single ID or expression (e.g. 100,200-300,450).', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    655958                                'sanitize_callback' => 'sanitize_text_field',
    656959                        ],
     960                        'category_operator' => self::category_operator_arg(),
    657961                        'tag' => [
    658962                                'description'       => __( 'Comma-separated tag term IDs.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    … …  
    685989                                'type'        => 'string',
    686990                        ],
    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                        ),
    7711015                        'menu_order_min' => [
    7721016                                '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  
    7777                                'enum'    => [ 'adaptive', '25', '50', '100' ],
    7878                        ],
     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                        ],
    7986                ];
    8087        }
    … …  
    204211                }
    205212
    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 ) );
    207217
    208218                // 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  
    2727                                'permission_callback' => [ Permission_Helper::class, 'rest_permission_check' ],
    2828                                'args'                => [
    29                                         'id' => [
     29                                        'id'      => [
    3030                                                'description'       => __( 'Product ID.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    3131                                                'type'              => 'integer',
    … …  
    3333                                                'sanitize_callback' => 'absint',
    3434                                        ],
     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(),
    3549                                ],
    3650                        ],
    … …  
    4862
    4963                $product_id = absint( $request->get_param( 'id' ) );
     64                $orderby    = (string) $request->get_param( 'orderby' );
     65                $order      = Products_Controller::resolve_sort_direction( $request );
    5066
    5167                $service = new Product_Service();
    52                 $result = $service->get_variations( $product_id );
     68                $result  = $service->get_variations( $product_id, $orderby, $order );
    5369
    5470                if ( is_wp_error( $result ) ) {
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Attribute_Service.php

    r3549389 r3736631  
    1919        private static $schema_cache = null;
    2020
     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
    2145        // TODO: Custom per-product text attributes (non-taxonomy) are not supported.
    2246        // Global taxonomy-backed attributes only. Per-product attributes are a different
    … …  
    2751         *
    2852         * 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.
    3057         *
    3158         * @return array Array of [ 'slug' => 'color', 'label' => 'Color', 'taxonomy' => 'pa_color', 'hierarchical' => false, 'orderby' => 'menu_order' ].
    … …  
    4269                }
    4370
     71                $unsafe = self::attribute_registry_hazards()['unsafe'];
    4472                $result = [];
    4573
    4674                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 );
    4982
    5083                        $result[] = [
    … …  
    141174
    142175        /**
    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).
    144405         */
    145406        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;
    147411        }
    148412}
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Export_Service.php

    r3700717 r3736631  
    7272                'slug'               => 'Slug',
    7373                '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',
    7476                'type'               => 'Type',
    7577                'status'             => 'Status',
    … …  
    256258                $dimension_unit = $units['dimension_unit'] ?? '';
    257259
     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
    258272                $headers = [];
    259273                foreach ( $columns as $col ) {
    … …  
    268282                        } elseif ( strncmp( $col, 'attr_pa_', 8 ) === 0 ) {
    269283                                $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                                );
    272289                        } elseif ( isset( $this->pro_columns[ $col ] ) ) {
    273290                                $label = $this->pro_columns[ $col ];
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Operations_Service.php

    r3663491 r3736631  
    3232         */
    3333        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;
    3467
    3568        /**
    … …  
    74107         * see the phase-2 comment. Count creations off created_ids; render rows off products.
    75108         *
     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         *
    76125         * @param int[] $product_ids Source product IDs.
    77126         * @param int   $count       Number of copies to create per source product.
    … …  
    87136                $failed             = [];
    88137                $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 = [];
    89150
    90151                if ( empty( $product_ids ) ) {
    … …  
    116177                        }
    117178
     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
    118193                        for ( $i = 1; $i <= $count; $i++ ) {
    119194                                $suffix    = ( 1 === $i ) ? ' (Copy)' : sprintf( ' (Copy %d)', $i );
    … …  
    121196                                $duplicate->set_id( 0 );
    122197                                $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 );
    144261                                }
    145262
     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
    146272                                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.
    149287                                        // 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.
    150288                                        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 {
    161301                                        $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;
    176307                                }
    177308                        }
    … …  
    240371                }
    241372
     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
    242386                if ( $format_failures > 0 ) {
    243387                        // Appended, never replacing — a run can hit both a variation failure and a format
    … …  
    258402
    259403        /**
     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        /**
    260438         * Duplicate all variations from source to new parent.
    261439         *
    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 ) {
    275462                        $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 ) {
    280494                        $new_variation = clone $child;
    281495                        $new_variation->set_id( 0 );
    282496                        $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 );
    285506                        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;
    294970        }
    295971
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/Product_Service.php

    r3700717 r3736631  
    99
    1010use WCABE_G2\Handlers\WP_Save_Hooks_Handler;
     11use WCABE_G2\Helpers\Html_Loss_Detector;
    1112use WCABE_G2\Helpers\Product_Helper;
     13use WCABE_G2\Helpers\Term_Helper;
    1214
    1315defined( 'ABSPATH' ) || exit;
    … …  
    1719        /** Allowed Title-search match modes. */
    1820        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' ];
    1924
    2025        /** Allowed Product URL filter match modes (mirrors ProductUrlOperator in TS). */
    … …  
    133138
    134139        /**
     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 `&nbsp;`-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        /**
    135164         * The installed core's own UTF-8 validity check.
    136165         *
    … …  
    154183
    155184        /**
    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 . '&amp;'` 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).
    157196         *
    158197         * Pure predicate. There is no way to ask WordPress: sanitize_title() has no
    … …  
    180219         * so a late registration runs last among the <= 9 callbacks — immediately
    181220         * before sanitize_title_with_dashes()), calls sanitize_title() exactly as the
    182          * save will, and has the observer measure the UNCAPPED encoding of the
     221         * save will, and has the observer test the tail the cap cuts off the
    183222         * string it receives. The last invocation wins: a re-entrant filter like
    184223         * WPML's fires the observer again with the expanded title, and that nested
    … …  
    188227         * stages — strip_tags(), the percent-octet dance (which also rewrites
    189228         * 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.
    196236         *
    197237         * Invalid UTF-8 mirrors core: no encoding, no cap, so never "too long" here.
    … …  
    205245                $exceeds  = false;
    206246                $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 );
    209249                        return $title;
    210250                };
    … …  
    221261
    222262        /**
    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().
    227343         *
    228344         * @param string $title Title as received by the priority-10 callback.
    229          * @return int|null
    230          */
    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 {
    232348                $title = strip_tags( $title ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- mirroring sanitize_title_with_dashes() byte-for-byte.
    233349                // Preserve escaped octets, drop lone percent signs, restore octets.
    … …  
    243359                }
    244360
    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 ) );
    246365        }
    247366
    … …  
    254373
    255374        /**
    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;
    273410
    274411        /**
    … …  
    284421        private const QUERY_MODE_IDS              = 'ids';
    285422        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})?$'";
    286448
    287449        /**
    … …  
    307469         */
    308470        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',
    310472                'cogs_value',
    311473                'date_created',
    … …  
    325487        private const ALLOWED_FIELDS = [
    326488                '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',
    328490                        'cogs_value',
    329491                        'date_created',
    … …  
    338500                ],
    339501                'variable' => [
    340                         'name', 'sku', 'slug', 'status', 'catalog_visibility',
     502                        'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    341503                        'cogs_value',
    342504                        'date_created',
    … …  
    350512                ],
    351513                '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',
    353515                        'cogs_value',
    354516                        'description',
    … …  
    361523                ],
    362524                'grouped'  => [
    363                         'name', 'sku', 'slug', 'status', 'catalog_visibility',
     525                        'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    364526                        'date_created',
    365527                        'categories', 'tags', 'description', 'short_description',
    … …  
    374536                ],
    375537                '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',
    377539                        'cogs_value',
    378540                        'date_created',
    … …  
    408570         */
    409571        private const DEFAULT_ALLOWED_FIELDS = [
    410                 'name', 'sku', 'slug', 'status', 'catalog_visibility',
     572                'name', 'sku', 'global_unique_id', 'slug', 'status', 'catalog_visibility',
    411573                'date_created',
    412574                'categories', 'tags', 'description', 'short_description',
    … …  
    506668                string $order = 'desc'
    507669        ): 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                }
    537745        }
    538746
    … …  
    550758         * @param bool          $include_variations Interleave variations under their parents.
    551759         * @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.
    552764         * @return array[] Formatted rows.
    553765         */
    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 {
    555767                // Allow Pro to prime caches before per-product formatting.
    556768                $product_ids = array_map( fn( $p ) => $p->get_id(), $wc_products );
    … …  
    637849                        }
    638850
     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
    639856                        $variation_ids_to_prime = [];
    640857                        foreach ( $children_map as $children ) {
    … …  
    660877                        }
    661878
    662                         $formatted = $this->interleave_variations( $formatted, $filters, $children_map );
     879                        $formatted = $this->interleave_variations( $formatted, $filters, $children_map, $child_sort );
    663880                }
    664881
    … …  
    672889         * @return array|\WP_Error
    673890         */
    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                }
    723951        }
    724952
    … …  
    7821010                string $order = 'desc'
    7831011        ): 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                                                }
    8091043                                        }
    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                }
    8521088        }
    8531089
    … …  
    10591295                // posts_join / posts_where fire on EVERY WP_Query for as long as they are registered —
    10601296                // 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().
    10651299                $join_callback = static function ( string $join, \WP_Query $query ) use ( $wpdb ): string {
    10661300                        if ( ! $query->get( self::REF_SEARCH_QUERY_VAR ) ) {
    … …  
    13361570                                                }
    13371571
    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 ) );
    13391582                                        }
    13401583
    … …  
    13581601                                        // escape batch_update() entirely and 500 the request. Same rule, and the same
    13591602                                        // 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
    13601620                                        try {
    13611621                                                do_action( 'wcabe_g2_after_product_save', $product, $changes, $context, $post_before, $save_token );
    … …  
    17662026                                        ];
    17672027                                } else {
     2028                                        $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag';
    17682029                                        foreach ( $value as $term_data ) {
    17692030                                                if ( ! is_array( $term_data ) || ( ! isset( $term_data['id'] ) && ! isset( $term_data['name'] ) ) ) {
    … …  
    17742035                                                        ];
    17752036                                                        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;
    17762047                                                }
    17772048                                        }
    … …  
    18402111                                                        __( 'SKU "%s" is already in use by another product.', 'wpmelon-advanced-bulk-edit-for-woocommerce' ),
    18412112                                                        $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
    18422141                                                ),
    18432142                                        ];
    … …  
    19952294         * @param mixed       $value
    19962295         * @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().
    19972299         * @throws \InvalidArgumentException If validation fails.
    19982300         */
    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 {
    20002302                switch ( $field ) {
    20012303                        case 'name':
    … …  
    20052307                        case 'sku':
    20062308                                $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 ) );
    20072316                                break;
    20082317
    … …  
    20742383                                // rather than clobbering it from the in-memory cogs_value prop.
    20752384                                // 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.
    20772388                                if ( Product_Helper::is_cogs_enabled() ) {
    20782389                                        $sanitized = trim( (string) $value );
    … …  
    21412452                        case 'categories':
    21422453                        case 'tags':
    2143                                 $this->apply_taxonomy_change( $product, $field, $value );
    2144                                 break;
     2454                                return $this->apply_taxonomy_change( $product, $field, $value );
    21452455
    21462456                        case 'description':
    2147                                 $product->set_description( wp_kses_post( (string) $value ) );
    2148                                 break;
    2149 
    21502457                        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                                }
    21522476                                break;
    21532477
    … …  
    22622586                                break;
    22632587                }
     2588
     2589                return [];
    22642590        }
    22652591
    … …  
    23632689         * Accepts an array of {id, name} objects.
    23642690         * - 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.
    23662693         *
    23672694         * @param \WC_Product $product
    23682695         * @param string      $field 'categories' or 'tags'.
    23692696         * @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 {
    23722701                if ( ! is_array( $value ) ) {
    2373                         return;
     2702                        return [];
    23742703                }
    23752704
    23762705                $taxonomy = 'categories' === $field ? 'product_cat' : 'product_tag';
    23772706                $term_ids = [];
     2707                $warnings = [];
    23782708
    23792709                foreach ( $value as $term_data ) {
    … …  
    23842714                        $term_id = isset( $term_data['id'] ) ? (int) $term_data['id'] : 0;
    23852715                        $name    = isset( $term_data['name'] ) ? sanitize_text_field( (string) $term_data['name'] ) : '';
     2716                        $parent  = isset( $term_data['parent'] ) ? absint( $term_data['parent'] ) : 0;
    23862717
    23872718                        if ( $term_id > 0 ) {
    … …  
    23892720                                if ( $term && ! is_wp_error( $term ) ) {
    23902721                                        $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 );
    23912726                                }
    23922727                        } 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;
    23972732                                } else {
    23982733                                        // Create new term.
    23992734                                        $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;
    24022737                                        }
    24032738                                        $result = wp_insert_term( $name, $taxonomy, $insert_args );
    24042739                                        if ( ! is_wp_error( $result ) ) {
    24052740                                                $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 );
    24062746                                        }
    24072747                                }
    … …  
    24142754                        $product->set_tag_ids( $term_ids );
    24152755                }
     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                );
    24162838        }
    24172839
    … …  
    24682890        ): array {
    24692891                $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;
    24702903
    24712904                // Every non-default sort is applied by build_sort_clauses() below, which
    … …  
    26153048                $has_product_url_filter = self::product_url_filter_is_effective( $product_url_operator, $product_url_value );
    26163049
    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.
    26183052                if ( ! empty( $filters['category'] ) ) {
    26193053                        $args['category'] = array_map( 'absint', (array) $filters['category'] );
     3054                        if ( 'not_in' === ( $filters['category_operator'] ?? 'in' ) ) {
     3055                                $args['wcabe_category_operator'] = 'not_in';
     3056                        }
    26203057                }
    26213058
    … …  
    27703207                $has_sale_date_filter    = isset( $args['wcabe_sale_start_min'] ) || isset( $args['wcabe_sale_start_max'] )
    27713208                        || 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
    27773225                        // Exact category matching — replace WC's default tax_query with include_children => false.
    27783226                        if ( ! empty( $query_vars['category'] ) ) {
    … …  
    27903238                                }
    27913239
    2792                                 $wp_query_args['tax_query'][] = [
     3240                                $category_clause = [
    27933241                                        'taxonomy'         => 'product_cat',
    27943242                                        'field'            => 'term_id',
    … …  
    27963244                                        'include_children' => false,
    27973245                                ];
    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 {
    28033266                                if ( ! empty( $wp_query_args['meta_query'] ) ) {
    28043267                                        $wp_query_args['meta_query'] = array_filter(
    … …  
    28103273                                        $wp_query_args['meta_query'] = array_values( $wp_query_args['meta_query'] );
    28113274                                }
     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 );
    28123286
    28133287                                $wp_query_args['meta_query'][] = [
    … …  
    29863460                        // Stock quantity range filtering via _stock meta.
    29873461                        // 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).
    29943467                        $has_stock_min = isset( $query_vars['wcabe_stock_qty_min'] );
    29953468                        $has_stock_max = isset( $query_vars['wcabe_stock_qty_max'] );
    … …  
    31093582                // variation with a matching _variation_description so the parent survives
    31103583                // 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.
    31113587                $description_where_callback = null;
    31123588                if ( $search_description_term ) {
    … …  
    31193595                                        // conversion must not surface its now-simple parent.
    31203596                                        $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.
    31223598                                        $where      .= $wpdb->prepare(
    31233599                                                " AND ( {$wpdb->posts}.post_content LIKE %s OR ( {$is_variable} AND {$wpdb->posts}.ID IN ("
    31243600                                                . " 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'
    31263602                                                . " WHERE p.post_type = 'product_variation'"
     3603                                                . " AND p.post_status IN ('publish','private')"
    31273604                                                . " AND pm.meta_key = '_variation_description'"
    31283605                                                . " AND pm.meta_value LIKE %s"
    … …  
    31313608                                                $desc_like
    31323609                                        );
    3133                                         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     3610                                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
    31343611                                } else {
    31353612                                        $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_content LIKE %s", $desc_like );
    … …  
    31953672                }
    31963673
    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 allows
    3199                 // 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_max
    3226                                         );
    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_min
    3231                                         );
    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_max
    3236                                         );
    3237                                 }
    3238                                 return $where;
    3239                         };
    3240                 }
    3241 
    32423674                // 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
    32443676                // WP's meta_query cannot express "meta row missing", which the is_empty
    32453677                // operator needs. Every column is alias-qualified — an unqualified
    32463678                // post_id/meta_key/meta_value becomes ambiguous the moment another meta
    32473679                // 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;
    32513682
    32523683                if ( $has_product_url_filter ) {
    32533684                        global $wpdb;
    32543685
    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 {
    32803687                                if ( strpos( $join, 'wcabe_product_url_meta' ) === false ) {
    3281                                         $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_product_url_meta ON ( {$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' ) ";
    32823689                                }
    32833690                                return $join;
    … …  
    32863693                        $is_external_sql = $this->sql_is_external_product();
    32873694
    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 {
    32923696
    32933697                                $col       = 'wcabe_product_url_meta.meta_value';
    … …  
    33503754
    33513755                // ── Combined variation-aware filtering: COGS, menu order, sale dates, image date,
    3352                 //    manage stock, backorders ──
     3756                //    manage stock, backorders, stock quantity, stock status ──
    33533757                //
    3354                 // ONE callback pair covers all six deliberately. Each of them can surface a
     3758                // ONE callback pair covers all eight deliberately. Each of them can surface a
    33553759                // variable parent either on its OWN value or through a child variation, and
    33563760                // emitting them as separate `( own_i OR child_i )` clauses would let *different*
    … …  
    33703774                // single-filter behaviour is unchanged.
    33713775                //
    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.
    33783788                //
    33793789                // The child predicates themselves live in build_variation_child_predicates()
    33803790                // ('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).
    33843792                //
    33853793                // The child status list matches WC get_children() (publish/private only) so a
    … …  
    33893797                // its now-simple parent (interleaving only narrows children of variable
    33903798                // parents, so such a false positive is uncorrectable downstream). COGS, sale
    3391                 // dates, manage stock and backorders participate only when include_variations
    3392                 // is on; with it off they stay on their meta_query paths and never reach this
    3393                 // callback, so with variations off only menu order and image date are emitted
    3394                 // 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.
    33953803                //
    33963804                // Manage stock / backorders child predicates use the value WooCommerce REPORTS
    … …  
    34103818                // The OWN predicates never coalesce: a product with no row must behave the same
    34113819                // 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).
    34123831                $combined_join_callback  = null;
    34133832                $combined_where_callback = null;
    … …  
    34353854                $use_ms    = $include_variations && $has_manage_stock_filter;
    34363855                $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;
    34373858
    34383859                $manage_stock_values = $use_ms ? array_values( array_map( 'strval', (array) $args['wcabe_manage_stock'] ) ) : [];
    34393860                $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 ) {
    34423869                        global $wpdb;
    34433870
    … …  
    34673894                        );
    34683895
    3469                         // Only COGS, sale dates, image date, manage stock and backorders need
    3470                         // 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 {
    34733900                                        if ( $use_cogs && strpos( $join, 'wcabe_cogs_meta' ) === false ) {
    3474                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_cogs_meta ON ( {$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' ) ";
    34753902                                        }
    34763903
    3477                                         // The parent's manage flag is needed for BOTH filters: backorders
    3478                                         // 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' ) ";
    34813908                                        }
    34823909
     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
    34833917                                        if ( $use_bk && strpos( $join, 'wcabe_bk_meta' ) === false ) {
    3484                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_bk_meta ON ( {$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' ) ";
    34853919                                        }
    34863920
    34873921                                        foreach ( $sale_predicates as $range ) {
    34883922                                                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']}' ) ";
    34903924                                                }
    34913925                                        }
    … …  
    34993933                                                // so a _thumbnail_id pointing at a non-attachment is excluded here
    35003934                                                // and renders blank there.
    3501                                                 $join .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_img_meta ON ( {$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' ) ";
    35023936                                                $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' ) ";
    35033937                                        }
    … …  
    35123946                        // spec keyed on $args, not $filters — a wcabe_g2_get_products_args hook
    35133947                        // may legitimately change which PARENTS surface (#218 keeps that intact).
    3514                         // stock_status / stock_qty are deliberately absent: they surface parents
    3515                         // through their own mechanisms (see the SCOPE note) and only the
    3516                         // candidate query (narrow_children_by_sql()) narrows children by them.
    35173948                        // The builder reads every axis through `! empty( $spec[...] )`, so an
    35183949                        // 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
    35193956                        $surface_spec = [
    35203957                                'cogs'         => $use_cogs ? [ 'min' => $cogs_min, 'max' => $cogs_max ] : null,
    … …  
    35243961                                'manage_stock' => $use_ms ? $manage_stock_values : null,
    35253962                                '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,
    35263965                                // 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',
    35333974                        ];
    35343975
    … …  
    35423983                                $use_ms,
    35433984                                $use_bk,
     3985                                $use_qty,
     3986                                $use_ss,
    35443987                                $sale_predicates,
    35453988                                $cogs_min,
    … …  
    35513994                                $manage_stock_values,
    35523995                                $backorders_values,
     3996                                $stock_qty_min,
     3997                                $stock_qty_max,
     3998                                $stock_status_values,
     3999                                $ss_ids,
    35534000                                $surface_spec
    35544001                        ): string {
    … …  
    35954042                                }
    35964043
     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
    35974062                                $own = empty( $own_parts ) ? '' : '( ' . implode( ' AND ', $own_parts ) . ' )';
    35984063
    … …  
    36014066                                        $fragment = $this->build_variation_child_predicates( $surface_spec, 'surface' );
    36024067                                        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']}"
    36044072                                                        . " WHERE c.post_parent = {$wpdb->posts}.ID AND c.post_type = 'product_variation'"
    36054073                                                        . " AND c.post_status IN ( 'publish', 'private' ) AND " . implode( ' AND ', $fragment['parts'] ) . ' ) )';
    … …  
    36264094                // (order_by_price_{asc,desc}_post_clauses in includes/class-wc-query.php).
    36274095                $sort_clauses_callback = null;
    3628                 $sort_marker_callback  = null;
    36294096
    36304097                $sort_spec = ( 'id' !== $orderby && ! $ids_only_all_variable )
    … …  
    36334100
    36344101                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 would
    3638                         // silently reorder (and join a foreign table into) a nested query. Both
    3639                         // callbacks below bail unless the query carries our private marker. The
    3640                         // marker filter itself also fires for every wc_get_products() call while
    3641                         // registered, so it only stamps queries whose ORIGINATING query vars carry
    3642                         // our sentinel (set on $args here; WC_Object_Query keeps unknown args via
    3643                         // 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 the
    3645                         // 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 
    36564102                        $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 );
    36574176                }
    36584177
    … …  
    36664185                }
    36674186
    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 );
    37084189                }
    37094190
    37104191                try {
     4192                        ++self::$products_query_depth;
    37114193                        $results = wc_get_products( $args );
    37124194                } 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                        }
    37134200                        if ( $needs_custom_query ) {
    37144201                                remove_filter(
    … …  
    37184205                                );
    37194206                        }
    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                        );
    37544220                }
    37554221
    … …  
    39244390
    39254391                $child_join = $negated
    3926                         ? "LEFT JOIN {$wpdb->postmeta} pm ON ( 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';
    39284394                $meta_key_where = $negated ? '' : " AND pm.meta_key = '_sku'";
    39294395
    … …  
    40634529
    40644530        /**
     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        /**
    40654724         * LEFT JOIN fragment resolving ONE postmeta row per product for a meta key,
    40664725         * under alias `$alias` (a wp_postmeta row: `$alias.meta_value`).
    … …  
    40814740         * @return string
    40824741         */
    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 {
    40844743                $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} )"
    40884754                        . " 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;
    40894855        }
    40904856
    … …  
    41004866         * @return string
    41014867         */
    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 {
    41034869                $taxonomy = esc_sql( $taxonomy );
     4870                $on       = '' === $on ? "{$wpdb->posts}.ID" : $on;
     4871                $restrict = self::sql_id_restriction( 'tr.object_id', $ids );
    41044872
    41054873                return " LEFT JOIN ( SELECT tr.object_id, MIN( t.slug ) AS slug, MIN( t.name ) AS name"
    … …  
    41074875                        . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id"
    41084876                        . " 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} ) ";
    41104878        }
    41114879
    … …  
    41184886         * @return string
    41194887         */
    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
    41214892                return " LEFT JOIN ( SELECT tr.object_id,"
    41224893                        . " MAX( t.slug = 'featured' ) AS featured,"
    … …  
    41264897                        . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id"
    41274898                        . " 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} ) ";
    41294900        }
    41304901
    … …  
    41374908        private const SORT_TYPE_ALIAS = 'wcabe_sort_ptype';
    41384909
    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 ];
    41414932        }
    41424933
    … …  
    41944985         *           (variable parents carry one `_price` row per child) nor from the
    41954986         *           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).
    41965029         *
    41975030         * Blank rows sort LAST in both directions, mirroring the grid's client-side
    … …  
    42255058                $specs = [
    42265059                        // 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' ],
    42345076
    42355077                        // wc_product_meta_lookup columns (one row per product, PK product_id).
    … …  
    42375079                        // are both SKU-less; lexical ordering under the table collation (typically
    42385080                        // *_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' ],
    42405088                        // stock_quantity is NULL when stock is not managed — blank, matching the
    42415089                        // "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' ],
    42465102                        // Price — mirrors WooCommerce core's order_by_price_{asc,desc}_post_clauses().
    42475103                        // Ascending ranks a variable parent by its cheapest child (min_price),
    … …  
    42515107                        // NULL. EXISTS is multi-row safe (variable parents carry one `_price` row
    42525108                        // 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).
    42535116                        '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                                },
    42635134                        ],
    42645135
    … …  
    42685139                        // _sale_price of their own (a stale one can survive a type change), so
    42695140                        // 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' ] ],
    42755150                        // Sale dates are stored as UNIX timestamps; format_product() blanks them
    42765151                        // 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' ],
    42795154
    42805155                        // postmeta strings. Both read as 'no' when the row is missing (WC's
    42815156                        // getters default; format_product() renders an unchecked box / "Do not
    42825157                        // 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' ],
    42855163                        // 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' ],
    42885166
    42895167                        // Taxonomy-backed and derived columns.
    … …  
    42935171                                'key'   => static fn( \wpdb $wpdb ): string => self::sql_product_type_expr(),
    42945172                                'type'  => 'string',
     5173                                'child' => 'none',
    42955174                        ],
    42965175                        // 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).
    42975178                        'shipping_class'     => [
    42985179                                '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                                },
    43005184                                'key'   => static fn( \wpdb $wpdb ): string => 'wcabe_sort_ship.name',
    43015185                                'type'  => 'string',
     5186                                'child' => 'same',
    43025187                        ],
    43035188                        // 0/1 flag; a product with no visibility term at all is simply not featured.
    … …  
    43075192                                'key'   => static fn( \wpdb $wpdb ): string => 'COALESCE( wcabe_sort_vis.featured, 0 )',
    43085193                                'type'  => 'numeric_native',
     5194                                'child' => 'none',
    43095195                        ],
    43105196                        // WC encodes visibility as two exclusion terms: both → hidden,
    … …  
    43185204                                        . " ELSE 'visible' END )",
    43195205                                'type'  => 'string',
     5206                                'child' => 'none',
    43205207                        ],
    43215208                        // Featured image upload date: the attachment's post_date. The post_type
    … …  
    43255212                        'image_date'         => [
    43265213                                '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                                },
    43295219                                'key'   => static fn( \wpdb $wpdb ): string => 'wcabe_sort_img.post_date',
    43305220                                'type'  => 'string',
     5221                                'child' => 'same',
    43315222                        ],
    43325223                ];
    … …  
    43355226                // render the column otherwise, and the REST enum rejects the key.
    43365227                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' ];
    43385229                }
    43395230
    … …  
    43625253        }
    43635254
    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' ];
    43675261
    43685262        /**
    … …  
    44115305                        }
    44125306                }
     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                }
    44135335                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} ) )";
    44145431        }
    44155432
    … …  
    44215438        public static function get_sortable_columns(): array {
    44225439                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                };
    44235460        }
    44245461
    … …  
    44425479         * product_type join with the `type` spec).
    44435480         *
     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         *
    44445484         * @param array<string, mixed> $spec One get_sort_specs() entry.
    44455485         * @param bool                 $asc  Sort direction.
    4446          * @return callable( array, \WP_Query ): array
     5486         * @return callable( array ): array
    44475487         */
    44485488        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 {
    44505490                        global $wpdb;
    44515491
    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 )";
    45235536                        }
    45245537
    … …  
    45365549                        return $clauses;
    45375550                };
     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                ];
    45385820        }
    45395821
    … …  
    46805962         */
    46815963        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';
    46836056        }
    46846057
    … …  
    47666139         *                  and the caller has already widened the bounds.
    47676140         *
    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
    47726146         * query does not re-join the same parent rows once per child row scanned.
    47736147         * When absent (the standalone candidate query has no outer row) the fragment
    … …  
    47836157         *                         null (never matches).
    47846158         *
     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         *
    47856168         * @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).
    47896173         * @param string $mode 'surface' or 'candidate'.
    47906174         * @return array{joins: string, parts: string[]}
    … …  
    48086192                // surfaces on a child the narrowing then drops.
    48096193                $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 );
    48136195                };
    48146196
    48156197                if ( ! empty( $spec['cogs'] ) ) {
    48166198                        $col     = 'wcabe_child_cogs.meta_value';
    4817                         $joins  .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_cogs ON ( 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' ) ";
    48186200                        $bound   = $candidate
    48196201                                ? self::sql_decimal_range_or_keep( $wpdb, $col, $spec['cogs']['min'], $spec['cogs']['max'] )
    … …  
    48306212                        // non-attachment means no row — the same "no match" PHP reaches through
    48316213                        // Product_Helper::get_image_upload_date().
    4832                         $joins  .= " INNER JOIN {$wpdb->postmeta} AS wcabe_child_img_meta ON ( 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' ) ";
    48336215                        $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' ) ";
    48346216                        $parts[] = self::sql_bound( $wpdb, 'wcabe_child_img.post_date', $spec['image']['min'], $spec['image']['max'], '%s' );
    … …  
    48376219                foreach ( (array) ( $spec['sale'] ?? [] ) as $range ) {
    48386220                        $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']}' ) ";
    48406222                        $bound   = $candidate
    48416223                                ? self::sql_timestamp_range_or_keep( $wpdb, $col, $range['min'], $range['max'] )
    … …  
    48506232                        // _backorders / _stock row must still exist for the inheritance branch,
    48516233                        // where the parent's value is used instead.
    4852                         $joins .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_ms ON ( 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' ) ";
    48536235
    48546236                        $parent_ms_col = $spec['parent_ms_col'] ?? null;
    48556237                        if ( null === $parent_ms_col ) {
    4856                                 $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pms ON ( 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' ) ";
    48576239                                $parent_ms_col = 'wcabe_child_pms.meta_value';
    48586240                        }
    … …  
    48696251
    48706252                        if ( ! empty( $spec['backorders'] ) ) {
    4871                                 $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_bk ON ( 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' ) ";
    48726254                                $parent_bk_col = $spec['parent_bk_col'] ?? null;
    48736255                                if ( null === $parent_bk_col ) {
    4874                                         $joins        .= " LEFT JOIN {$wpdb->postmeta} AS wcabe_child_pbk ON ( 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' ) ";
    48756257                                        $parent_bk_col = 'wcabe_child_pbk.meta_value';
    48766258                                }
    … …  
    48796261
    48806262                        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} )";
    48906283                        }
    48916284                }
    … …  
    48966289                        // when 'instock' is selected, every such row must be KEPT; otherwise it
    48976290                        // 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.
    48986295                        $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() ) . ' )'
    49046301                                : $in;
    49056302                }
    … …  
    49096306                        'parts' => $parts,
    49106307                ];
     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;
    49116430        }
    49126431
    … …  
    50276546
    50286547                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;
    50296826        }
    50306827
    … …  
    51236920                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    51246921
    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 ) {
    51376924                                foreach ( $chunk as $child_id ) {
    51386925                                        foreach ( $child_parents[ $child_id ] as $parent_id ) {
    … …  
    51436930                        }
    51446931
    5145                         foreach ( (array) $wpdb->last_result as $row ) {
     6932                        foreach ( $rows as $row ) {
    51466933                                $survivor_set[ (int) $row->ID ] = true;
    51476934                        }
    … …  
    51826969        // TODO: Phase 5 — consider capping variations per product when include_variations is on,
    51836970        // 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 {
    51856972                $result          = [];
    51866973                $has_meta_filter = $this->has_variation_meta_filters( $filters );
    … …  
    51896976                $attribute_slugs = $this->resolve_attribute_filter_slugs( $filters['attribute_filter'] ?? [] );
    51906977
    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 ) {
    51946983                        // variation_count is only non-zero for variable-capable products
    51956984                        // (format_product gates it on instanceof WC_Product_Variable), so it
    … …  
    52417030                                }
    52427031
     7032                                $rows = [];
    52437033                                foreach ( $children as $child_id ) {
    52447034                                        $variation = wc_get_product( $child_id );
    … …  
    52477037                                                        continue;
    52487038                                                }
    5249                                                 $result[] = Product_Helper::format_variation( $variation );
     7039                                                $rows[] = Product_Helper::format_variation( $variation );
    52507040                                        }
    52517041                                }
     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;
    52527054                        }
    52537055                }
    … …  
    52567058        }
    52577059
    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,
    52717067         * 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 {
    52897078                global $wpdb;
    52907079                $variation_count = 0;
    … …  
    53017090                        // renders those. Counting draft/pending here would over-report rows
    53027091                        // that never display. Parent status filtering is already applied via
    5303                         // the parent-ID query above; this clause is the independent
     7092                        // get_matching_variable_parent_ids(); this clause is the independent
    53047093                        // VARIATION-status axis in WC's data model.
    53057094                        // 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.
    … …  
    53167105                }
    53177106
    5318                 return [
    5319                         'total'          => $total_parents + $variation_count,
    5320                         'is_approximate' => $this->has_variation_meta_filters( $filters ),
    5321                 ];
     7107                return $variation_count;
    53227108        }
    53237109
    … …  
    57577543                                continue;
    57587544                        }
    5759                         $resolved[ sanitize_title( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );
     7545                        $resolved[ Attribute_Service::attribute_key( $taxonomy ) ] = array_values( array_map( 'strval', $slugs ) );
    57607546                }
    57617547                return $resolved;
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/includes/Services/View_Service.php

    r3663491 r3736631  
    140140                }
    141141
    142                 update_user_meta( $user_id, 'wcabe_g2_views', $views );
     142                $this->persist( $user_id, $views );
    143143
    144144                return $view;
    … …  
    203203                // create_view sanitize on the touched view is sufficient since other
    204204                // 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 );
    206206
    207207                return $views[ $index ];
    … …  
    241241                // create_view sanitize on the touched view is sufficient since other
    242242                // 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 );
    244244
    245245                return true;
    … …  
    374374                }
    375375
     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
    376386                // Product URL filter. Canonicalized here as well as in the client store,
    377387                // because a view can also be created/imported straight through the REST
    … …  
    396406                        'search_sku'              => $search_sku,
    397407                        'search_sku_mode'         => $search_sku_mode,
    398                         'category'                => array_map( 'absint', (array) ( $filters['category'] ?? [] ) ),
     408                        'category'                => $category,
     409                        'category_operator'       => $category_operator,
    399410                        'tag'                     => array_map( 'absint', (array) ( $filters['tag'] ?? [] ) ),
    400411                        'type'                    => array_map( 'sanitize_text_field', (array) ( $filters['type'] ?? [] ) ),
    … …  
    520531
    521532        /**
     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        /**
    522549         * Ensure only the given view ID is marked as default.
    523550         *
  • wpmelon-advanced-bulk-edit-for-woocommerce/trunk/readme.txt

    r3700717 r3736631  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.3.0
     7Stable tag: 1.4.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3131* **Fill handle** — drag a cell down (or up) to copy its value to neighboring cells, Excel-style
    3232* **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 more
     33* **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
    3434* **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 more
     35* **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
    3636* **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 end
     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, empty values stay at the end, and variable products rank by their variations
    3838* **Keyboard shortcuts** — navigate and edit without touching the mouse; arrow keys move between cells even while you are typing
    3939* **Product operations** — duplicate and change status right from the grid
    … …  
    5454= Pro version =
    5555
    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/).
     56If 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/).
    5757
    5858The 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.
    … …  
    134134
    135135== 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 `&nbsp;`.
     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.
    136156
    137157= 1.3.0 =
    … …  
    217237== Upgrade Notice ==
    218238
     239= 1.4.0 =
     240Visual description editor, GTIN column, Category "Is none of" filter, variation-aware sorting, WooCommerce-style duplicates, page-cache fixes, and many fixes.
     241
    219242= 1.3.0 =
    220243Whole-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  
    44 * Plugin URI: https://wpmelon.com/bulk-edit/
    55 * Description: Excel-like bulk editing interface for WooCommerce products
    6  * Version: 1.3.0
     6 * Version: 1.4.0
    77 * Author: WPMelon
    88 * Author URI: https://wpmelon.com/
    … …  
    1414 * Requires Plugins: woocommerce
    1515 * WC requires at least: 10.0
    16  * WC tested up to: 11.1.0
     16 * WC tested up to: 11.2.0
    1717 * License: GPLv2 or later
    1818 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    126126define( 'WCABE_G2_LOADED', true );
    127127
    128 define( 'WCABE_G2_VERSION', '1.3.0' );
     128define( 'WCABE_G2_VERSION', '1.4.0' );
    129129define( 'WCABE_G2_PLUGIN_FILE', __FILE__ );
    130130define( 'WCABE_G2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.