| @@ -7,75 +7,201 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V2; |
| 9 | 9 | |
| 10 | 10 | use WC_Product_Variation; |
| 11 | -use WCPOS\WooCommercePOS\Services\Barcode_Field; | |
| 11 | +use WC_REST_Product_Variations_Controller; | |
| 12 | 12 | use WCPOS\WooCommercePOS\Sync\Api; |
| 13 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules; | |
| 14 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; | |
| 13 | 15 | use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 14 | 16 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 15 | -use WCPOS\WooCommercePOS\Sync\Pos_Visibility; | |
| 17 | +use WCPOS\WooCommercePOS\Sync\Product_Search; | |
| 16 | 18 | use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| 17 | 19 | use WP_Error; |
| 18 | -use WP_REST_Controller; | |
| 20 | +use WP_Query; | |
| 19 | 21 | use WP_REST_Request; |
| 22 | +use WP_REST_Response; | |
| 20 | 23 | use WP_REST_Server; |
| 21 | 24 | |
| 22 | 25 | // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 23 | 26 | |
| 24 | 27 | /** |
| 25 | - * Variations document endpoint (on-demand variation fetch). | |
| 28 | + * Variations document endpoint — the collection's hydration AND list/seed lane (ADR 0034). | |
| 26 | 29 | * |
| 27 | - * Why: the change-signal yields BARE variation ids (no parent), and wc/v3 has no | |
| 28 | - * cross-parent `variations?include=` — its only variation route is the | |
| 29 | - * parent-mediated `products/<parent>/variations`. This lab endpoint resolves the | |
| 30 | - * parent server-side (off the loaded WC_Product_Variation, zero extra SQL) and | |
| 31 | - * hydrates through the SAME filtered products-controller path used by | |
| 32 | - * resolve/changes, so the client pulls a deferred variation set in ONE round trip | |
| 33 | - * with no parent->child dance. Extends wc/v3 in our `{API_NAMESPACE}` namespace. | |
| 30 | + * Why a flat route: the change-signal yields BARE variation ids (no parent), and WooCommerce's | |
| 31 | + * only variation routes are parent-mediated (`products/<parent>/variations`). One flat route | |
| 32 | + * IS the cross-parent collection: bare pages seed the complete replica (the idle trickle), | |
| 33 | + * `include=` is one filter on it (targeted hydration, no parent->child dance), and the | |
| 34 | + * SKU/barcode discovery search is another. | |
| 35 | + * | |
| 36 | + * Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed. | |
| 37 | + * WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in | |
| 38 | + * the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its | |
| 39 | + * own collection params. 1.9.x did exactly this: `parent::get_items( $request )`, one line | |
| 40 | + * (`API\V1\Product_Variations_Controller::wcpos_get_all_items`). | |
| 41 | + * | |
| 42 | + * The previous version of this class extended a bare `WP_REST_Controller` and rebuilt the query | |
| 43 | + * by hand — ~90 lines of raw postmeta SQL, five hand-declared args, no item schema — on the | |
| 44 | + * stated grounds that "wc/v3 has no cross-parent variations?include=". That claim was false, and | |
| 45 | + * the cost of acting on it was the payload: a variation hydrated through the PRODUCTS controller | |
| 46 | + * carries `images[]` instead of `image`, which blanked every variation thumbnail in the POS on | |
| 47 | + * 1.10.0 and wrote the parent's image onto every order line (#1710). | |
| 48 | + * | |
| 49 | + * What stays ours, and only this: the sync document envelope the engine reads | |
| 50 | + * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier | |
| 51 | + * search, and the request bounds. Everything else is WooCommerce's. | |
| 34 | 52 | */ |
| 35 | -class Variations_Controller extends WP_REST_Controller { | |
| 53 | +class Variations_Controller extends WC_REST_Product_Variations_Controller { | |
| 54 | + /** | |
| 55 | + * Request keys the variation Collection Rules plan reads on this lane. | |
| 56 | + * | |
| 57 | + * @var array | |
| 58 | + */ | |
| 59 | + private const WCPOS_SORT_PARAM_MAP = array( | |
| 60 | + 'orderby' => 'orderby', | |
| 61 | + 'order' => 'order', | |
| 62 | + 'search' => 'search', | |
| 63 | + ); | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * The request whose declared rules wrap the collection query. | |
| 67 | + * | |
| 68 | + * @var null|WP_REST_Request | |
| 69 | + */ | |
| 70 | + private $wcpos_sort_request = null; | |
| 71 | + | |
| 36 | 72 | use Endpoint_Permissions; |
| 37 | 73 | |
| 38 | 74 | private const MAX_SKU_LENGTH = 4096; |
| 39 | 75 | private const MAX_SKU_TERMS = 100; |
| 40 | 76 | private const MAX_SEARCH_LENGTH = 256; |
| 41 | - private const MAX_SEARCH_TERMS = 10; | |
| 42 | 77 | private const MAX_PAGE = 1000; |
| 43 | 78 | |
| 44 | 79 | |
| 45 | 80 | public function register_routes(): void { |
| 81 | + /* | |
| 82 | + * ONLY the flat sync route. `parent::register_routes()` is deliberately not called: the | |
| 83 | + * v2 namespace is a read/sync surface, and writes ride Write_Controller, which already | |
| 84 | + * pushes through WooCommerce's nested routes. Registering WC's CRUD routes here would | |
| 85 | + * widen the POS-marker-gated surface for no consumer. | |
| 86 | + * | |
| 87 | + * The args and the schema are WooCommerce's own, so `include`, `search`, `orderby`, | |
| 88 | + * `order`, `offset`, `page`, `per_page`, `status` … all behave exactly as they do on | |
| 89 | + * wc/v3, and the route documents itself in the REST index. | |
| 90 | + */ | |
| 46 | 91 | register_rest_route( |
| 47 | 92 | Api::ROUTE_NAMESPACE, |
| 48 | 93 | '/variations', |
| 49 | 94 | array( |
| 50 | - 'methods' => WP_REST_Server::READABLE, | |
| 51 | - 'callback' => array( $this, 'get_variations' ), | |
| 52 | - 'permission_callback' => array( $this, 'permissions_check' ), | |
| 53 | - 'args' => array( | |
| 54 | - 'include' => array( 'sanitize_callback' => 'wp_parse_id_list' ), | |
| 55 | - 'search' => array( 'sanitize_callback' => 'sanitize_text_field' ), | |
| 56 | - 'sku' => array( 'sanitize_callback' => 'sanitize_text_field' ), | |
| 57 | - 'per_page' => array( | |
| 58 | - 'default' => 10, | |
| 59 | - 'sanitize_callback' => 'absint', | |
| 60 | - ), | |
| 61 | - 'page' => array( | |
| 62 | - 'default' => 1, | |
| 63 | - 'sanitize_callback' => 'absint', | |
| 64 | - ), | |
| 95 | + array( | |
| 96 | + 'methods' => WP_REST_Server::READABLE, | |
| 97 | + 'callback' => array( $this, 'get_variations' ), | |
| 98 | + 'permission_callback' => array( $this, 'permissions_check' ), | |
| 99 | + 'args' => $this->get_collection_params(), | |
| 65 | 100 | ), |
| 101 | + 'schema' => array( $this, 'get_public_item_schema' ), | |
| 66 | 102 | ) |
| 67 | 103 | ); |
| 68 | 104 | } |
| 69 | 105 | |
| 70 | 106 | /** |
| 71 | - * GET /variations?include=12,34,56 — hydrate the given variation ids. | |
| 107 | + * Narrow WooCommerce's variation query to what the POS may serve. | |
| 72 | 108 | * |
| 73 | - * Mirrors the wc/v3 `products?include=` shape; the parent is resolved | |
| 74 | - * server-side off the loaded variation object (get_parent_id), so the client | |
| 75 | - * never needs to know parents. Unknown / non-variation ids are skipped | |
| 76 | - * (deletes are handled by the change-signal tombstone path, not here). | |
| 109 | + * Everything WooCommerce already understands — `include`, `offset`, `order`, pagination, | |
| 110 | + * status — comes from `parent::prepare_objects_query()`, which also applies | |
| 111 | + * `woocommerce_rest_product_variation_object_query` internally (wc/v3's CRUD controller fires | |
| 112 | + * it there, not in `get_items()`), so third-party query scoping reaches every lane built | |
| 113 | + * through this method. Layered on top — deliberately AFTER that filter, so a third party | |
| 114 | + * cannot widen what the POS may serve: POS visibility, the | |
| 115 | + * barcode-carrier search, and the sort keys the POS grids offer. This is the seam 1.9.x used | |
| 116 | + * for the same job (`API\V1\Product_Variations_Controller::prepare_objects_query`). | |
| 117 | + * | |
| 118 | + * @param WP_REST_Request $request Full details about the request. | |
| 119 | + * | |
| 120 | + * @return array | |
| 77 | 121 | */ |
| 122 | + protected function prepare_objects_query( $request ) { | |
| 123 | + /* | |
| 124 | + * WooCommerce splits `sku` on commas without trimming, so `sku=A, B` looks for " B". | |
| 125 | + * Normalize before it sees the param rather than reimplementing its matching. | |
| 126 | + */ | |
| 127 | + $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); | |
| 128 | + if ( '' !== $sku ) { | |
| 129 | + $terms = array_values( | |
| 130 | + array_filter( | |
| 131 | + array_map( 'trim', explode( ',', $sku ) ), | |
| 132 | + static function ( string $term ): bool { | |
| 133 | + return '' !== $term; | |
| 134 | + } | |
| 135 | + ) | |
| 136 | + ); | |
| 137 | + $sku = implode( ',', $terms ); | |
| 138 | + $request->set_param( 'sku', $sku ); | |
| 139 | + } | |
| 140 | + | |
| 141 | + $args = parent::prepare_objects_query( $request ); | |
| 142 | + | |
| 143 | + /* | |
| 144 | + * A product is not a variation document. | |
| 145 | + * | |
| 146 | + * WooCommerce widens `post_type` to `array( 'product', 'product_variation' )` whenever | |
| 147 | + * `sku` is set, because the two share one SKU space. On THIS route that would serve a | |
| 148 | + * simple product as a variation — and the client would file it into its variations | |
| 149 | + * collection, the mirror image of the misfiled-variation pollution it already carries a | |
| 150 | + * one-shot repair for. Type purity on a variations route is ours to enforce. | |
| 151 | + */ | |
| 152 | + $args['post_type'] = $this->post_type; | |
| 153 | + | |
| 154 | + /* | |
| 155 | + * This route only ever offers what the store owner has for sale — on EVERY lane, including | |
| 156 | + * `include`. | |
| 157 | + * | |
| 158 | + * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private` | |
| 159 | + * when unchecked ({@see \WC_Meta_Box_Product_Data::save_variations()}), and WooCommerce | |
| 160 | + * honours that everywhere a customer can reach: `get_visible_children()` and | |
| 161 | + * `get_available_variations()` both drop it. A cashier must not be able to sell a variation | |
| 162 | + * the owner switched off, so the POS behaves the same way. | |
| 163 | + * | |
| 164 | + * The `include` lane is NOT exempt. Being asked for an id by name is not evidence the owner | |
| 165 | + * wants it sold: the client learns those ids from the parent's `variations[]`, which | |
| 166 | + * WooCommerce fills from `get_children()` — publish AND private — and from the change | |
| 167 | + * signal, which journals a disabled variation like any other post. A disabled id simply is | |
| 168 | + * not hydrated, the client's targeted-pull shortfall prunes it, and it leaves every till. | |
| 169 | + * Re-enabling saves the variation, which journals it, and it comes back. | |
| 170 | + * | |
| 171 | + * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it. | |
| 172 | + */ | |
| 173 | + $args['post_status'] = 'publish'; | |
| 174 | + | |
| 175 | + $this->wcpos_sort_request = $request; | |
| 176 | + $plan = Collection_Rules::for_request( 'variations', $request, self::WCPOS_SORT_PARAM_MAP ); | |
| 177 | + $args = $plan->filter( Collection_Rules_Plan::HOOK_PREPARE_ARGS, $args ); | |
| 178 | + | |
| 179 | + return $args; | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** Apply the same rule topology to discovery, collection pages, and named includes. */ | |
| 183 | + protected function get_objects( $query_args ) { | |
| 184 | + $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 185 | + return $plan->around( | |
| 186 | + function () use ( $query_args ) { | |
| 187 | + return parent::get_objects( $query_args ); | |
| 188 | + } | |
| 189 | + ); | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** | |
| 193 | + * GET /variations — the flat collection's three lanes, one response shape. | |
| 194 | + * | |
| 195 | + * `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one | |
| 196 | + * collection page (the trickle's seed lane); `?include=12,34` hydrates the | |
| 197 | + * named ids. All three resolve ids through WooCommerce's collection query, | |
| 198 | + * then hydrate through the shared assembly line below. Mirrors the wc/v3 | |
| 199 | + * `products?include=` shape; the parent is resolved server-side off the | |
| 200 | + * loaded variation object (get_parent_id), so the client never needs to know | |
| 201 | + * parents. Unknown / non-variation ids are skipped (deletes are handled by | |
| 202 | + * the change-signal tombstone path, not here). | |
| 203 | + */ | |
| 78 | 204 | public function get_variations( WP_REST_Request $request ) { |
| 79 | 205 | $started = microtime( true ); |
| 80 | 206 | $search_meta = null; |
| 81 | 207 | if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) { |
| @@ -83,17 +209,71 @@ | ||
| 83 | 209 | if ( is_wp_error( $validation ) ) { |
| 84 | 210 | return $validation; |
| 85 | 211 | } |
| 86 | 212 | list( $ids, $search_meta ) = $this->search_variation_ids( $request ); |
| 213 | + } elseif ( array() === array_filter( (array) $request->get_param( 'include' ) ) ) { | |
| 214 | + /* | |
| 215 | + * A bare collection request — no `include`, no discovery term — answers page one of the | |
| 216 | + * POS-servable set with WooCommerce's own pagination, exactly as its `get_items()` would. | |
| 217 | + * | |
| 218 | + * This used to be a 400. That refusal is why the client still counts variations on the | |
| 219 | + * FROZEN `wcpos/v1` lane — the single remaining v1 call in the app — because the census | |
| 220 | + * probes a collection route and reads `X-WP-Total`, and no v2 variations route could | |
| 221 | + * answer "how many". Refusing the question was never a safety property: `include` is a | |
| 222 | + * filter, and a collection route with no filter is a collection. | |
| 223 | + */ | |
| 224 | + list( $ids, $search_meta ) = $this->collection_page( $request ); | |
| 87 | 225 | } else { |
| 88 | - $ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); | |
| 89 | - if ( array() === $ids ) { | |
| 90 | - return new WP_Error( 'woocommerce_pos_sync_missing_ids', 'variations requires a non-empty include list', array( 'status' => 400 ) ); | |
| 226 | + /* | |
| 227 | + * The ask runs through the SAME query WooCommerce's own collection read builds | |
| 228 | + * (#1751): `parent::prepare_objects_query()` maps `include` to `post__in` and — in | |
| 229 | + * wc/v3's CRUD controller — applies `woocommerce_rest_product_variation_object_query` | |
| 230 | + * internally, so third-party query scoping reaches this lane like every other | |
| 231 | + * (hook-parity contract #1738). The collection and discovery lanes always had that | |
| 232 | + * property; this lane loaded ids directly and bypassed it. POS visibility and the | |
| 233 | + * publish gate ride the same args (layered in our override). | |
| 234 | + * | |
| 235 | + * The paging/ordering params are PINNED, not honoured: this lane answers a named | |
| 236 | + * ask, so `per_page` covers the whole ask, `offset`/`page` cannot skip any of it | |
| 237 | + * (a skipped id is absent from documents, which the client reads as "prune this | |
| 238 | + * id"), and `orderby=include` keeps WooCommerce from ordering by a meta key whose | |
| 239 | + * EXISTS join would silently drop every variation lacking that meta row. Pinning | |
| 240 | + * `orderby` also keeps the args complete for direct (non-dispatched) invocations, | |
| 241 | + * which carry no route defaults. Served order is the include order either way — | |
| 242 | + * the intersect below is the final authority. | |
| 243 | + */ | |
| 244 | + $include_ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); | |
| 245 | + // Pins live on a QUERY-ONLY clone: the dispatched request stays exactly | |
| 246 | + // as the client sent it, for the serializer's prepare-filters and for | |
| 247 | + // anything downstream reading it after dispatch. | |
| 248 | + $query_request = clone $request; | |
| 249 | + $query_request->set_param( 'per_page', max( 1, count( $include_ids ) ) ); | |
| 250 | + $query_request->set_param( 'page', 1 ); | |
| 251 | + $query_request->set_param( 'offset', 0 ); | |
| 252 | + $query_request->set_param( 'orderby', 'include' ); | |
| 253 | + $query_request->set_param( 'order', 'asc' ); | |
| 254 | + $args = $this->prepare_objects_query( $query_request ); | |
| 255 | + | |
| 256 | + /* | |
| 257 | + * The ask is a CEILING. WooCommerce's variations controller UNIONS some | |
| 258 | + * collection params into `post__in` (`on_sale=true` array-unions every on-sale | |
| 259 | + * id on top of the ask), so without this intersection a stray param would | |
| 260 | + * hydrate the whole store into the till. No request param or filter may widen | |
| 261 | + * the served set beyond the named ids — narrowing is fine, that is what the | |
| 262 | + * object_query filter and the visibility exclusion are for. An emptied ask pins | |
| 263 | + * to `array( 0 )`, the same never-matches sentinel Pos_Visibility uses. | |
| 264 | + */ | |
| 265 | + $post_in = array_values( array_intersect( array_map( 'intval', (array) ( $args['post__in'] ?? array() ) ), $include_ids ) ); | |
| 266 | + $args['post__in'] = array() === $post_in ? array( 0 ) : $post_in; | |
| 267 | + $results = $this->get_objects( $args ); | |
| 268 | + | |
| 269 | + $allowed_ids = array(); | |
| 270 | + foreach ( $results['objects'] as $object ) { | |
| 271 | + if ( $object instanceof WC_Product_Variation ) { | |
| 272 | + $allowed_ids[] = $object->get_id(); | |
| 273 | + } | |
| 91 | 274 | } |
| 92 | - // Leg-3 (ADR 0014 WP-M5): drop POS-hidden (`online_only`) variations from the served set. A hidden | |
| 93 | - // id simply isn't hydrated → the client's targeted pull returns nothing for it → Leg-3 prunes it. | |
| 94 | - // (Products get the equivalent exclusion via the catalog-proxy `post__not_in` filter.) | |
| 95 | - $ids = ( new Pos_Visibility() )->filter_visible_children( $ids ); | |
| 275 | + $ids = array_values( array_intersect( $include_ids, $allowed_ids ) ); | |
| 96 | 276 | } |
| 97 | 277 | _prime_post_caches( $ids, true, true ); |
| 98 | 278 | |
| 99 | 279 | // Hydrate through THE product assembly line (Product_Serializer), the same |
| @@ -112,10 +292,15 @@ | ||
| 112 | 292 | $digests = class_exists( Digest_Index::class ) |
| 113 | 293 | ? ( new Digest_Index() )->read_digests( 'products', $ids ) |
| 114 | 294 | : array(); |
| 115 | 295 | |
| 116 | - $serialization_request = new WP_REST_Request( 'GET', '/' ); | |
| 117 | - $serializer = new Product_Serializer(); | |
| 296 | + $serializer = new Product_Serializer(); | |
| 297 | + // A CLONE of the live request, not a synthetic bare one (so prepare-filters | |
| 298 | + // see the real request context), and not the live request itself (the | |
| 299 | + // serializer stamps store scope and a per-variation `product_id` onto | |
| 300 | + // whatever it is handed; the dispatched request must leave this method as | |
| 301 | + // the client sent it). | |
| 302 | + $serialization_request = clone $request; | |
| 118 | 303 | $documents = array(); |
| 119 | 304 | foreach ( $ids as $id ) { |
| 120 | 305 | $variation = wc_get_product( $id ); |
| 121 | 306 | if ( ! $variation instanceof WC_Product_Variation ) { |
| @@ -120,8 +305,20 @@ | ||
| 120 | 305 | $variation = wc_get_product( $id ); |
| 121 | 306 | if ( ! $variation instanceof WC_Product_Variation ) { |
| 122 | 307 | continue; |
| 123 | 308 | } |
| 309 | + /* | |
| 310 | + * DISABLED variations are never hydrated — see the `post_status` note in | |
| 311 | + * prepare_objects_query(). The query-level publish gate covers ALL lanes, including | |
| 312 | + * `include`; this check only guards a status change between the id query and object load. | |
| 313 | + * | |
| 314 | + * `meta.requested` now counts the query-eligible ask: a disabled or query-filtered id is | |
| 315 | + * absent from $ids. The client's targeted-pull shortfall — absence from documents — is | |
| 316 | + * unchanged. | |
| 317 | + */ | |
| 318 | + if ( 'publish' !== $variation->get_status() ) { | |
| 319 | + continue; | |
| 320 | + } | |
| 124 | 321 | $payload = $serializer->serialize( $variation, $serialization_request ); |
| 125 | 322 | $document = array( |
| 126 | 323 | 'id' => $id, |
| 127 | 324 | 'parent_id' => (int) $variation->get_parent_id(), |
| @@ -141,14 +338,32 @@ | ||
| 141 | 338 | if ( null !== $search_meta ) { |
| 142 | 339 | $meta = array_merge( $meta, $search_meta ); |
| 143 | 340 | } |
| 144 | 341 | |
| 145 | - return rest_ensure_response( | |
| 342 | + $response = rest_ensure_response( | |
| 146 | 343 | array( |
| 147 | 344 | 'documents' => $documents, |
| 148 | 345 | 'meta' => $meta, |
| 149 | 346 | ) |
| 150 | 347 | ); |
| 348 | + | |
| 349 | + /* | |
| 350 | + * The pagination WooCommerce would have sent. | |
| 351 | + * | |
| 352 | + * No v2 route emitted `X-WP-Total`/`X-WP-TotalPages` — including this one, the only one | |
| 353 | + * that paginates. The client asks for them on every v2 GET (the response envelope mirrors | |
| 354 | + * them into the body), so it has been receiving an empty mirror and falling back to | |
| 355 | + * short-page detection, which cannot tell "last page" from "the server truncated". | |
| 356 | + */ | |
| 357 | + if ( null !== $search_meta && $response instanceof WP_REST_Response ) { | |
| 358 | + $response->header( 'X-WP-Total', (string) $search_meta['total'] ); | |
| 359 | + $response->header( | |
| 360 | + 'X-WP-TotalPages', | |
| 361 | + (string) ( $search_meta['per_page'] > 0 ? (int) ceil( $search_meta['total'] / $search_meta['per_page'] ) : 0 ) | |
| 362 | + ); | |
| 363 | + } | |
| 364 | + | |
| 365 | + return $response; | |
| 151 | 366 | } |
| 152 | 367 | |
| 153 | 368 | /** |
| 154 | 369 | * Reject search requests that could build excessively large SQL queries or offsets. |
| @@ -155,29 +370,35 @@ | ||
| 155 | 370 | * |
| 156 | 371 | * @return true|WP_Error |
| 157 | 372 | */ |
| 158 | 373 | private function validate_search_request( WP_REST_Request $request ) { |
| 159 | - if ( $request->has_param( 'sku' ) ) { | |
| 160 | - $sku = (string) $request->get_param( 'sku' ); | |
| 374 | + $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); | |
| 375 | + $skus = array_filter( | |
| 376 | + array_map( 'trim', explode( ',', $sku ) ), | |
| 377 | + static function ( string $term ): bool { | |
| 378 | + return '' !== $term; | |
| 379 | + } | |
| 380 | + ); | |
| 381 | + if ( array() !== $skus ) { | |
| 382 | + $sku = implode( ',', $skus ); | |
| 161 | 383 | if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) { |
| 162 | 384 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) ); |
| 163 | 385 | } |
| 164 | - $skus = array_filter( | |
| 165 | - array_map( 'trim', explode( ',', $sku ) ), | |
| 166 | - static function ( string $term ): bool { | |
| 167 | - return '' !== $term; | |
| 168 | - } | |
| 169 | - ); | |
| 170 | 386 | if ( self::MAX_SKU_TERMS < \count( $skus ) ) { |
| 171 | 387 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) ); |
| 172 | 388 | } |
| 173 | 389 | } else { |
| 174 | 390 | $search = (string) $request->get_param( 'search' ); |
| 175 | - if ( self::MAX_SEARCH_LENGTH < \strlen( $search ) ) { | |
| 176 | - return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 bytes', array( 'status' => 400 ) ); | |
| 391 | + // Unlike mb_strlen(), PCRE is independent of blog_charset and detects malformed UTF-8. | |
| 392 | + $characters = preg_match_all( '/./us', $search ); | |
| 393 | + if ( false === $characters ) { | |
| 394 | + return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); | |
| 177 | 395 | } |
| 178 | - $terms = (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 179 | - if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { | |
| 396 | + if ( self::MAX_SEARCH_LENGTH < $characters ) { | |
| 397 | + return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 characters', array( 'status' => 400 ) ); | |
| 398 | + } | |
| 399 | + $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 400 | + if ( Collection_Rules::rules( 'variations' )['search']['term_cap'] < \count( $terms ) ) { | |
| 180 | 401 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); |
| 181 | 402 | } |
| 182 | 403 | } |
| 183 | 404 | |
| @@ -188,71 +409,175 @@ | ||
| 188 | 409 | return true; |
| 189 | 410 | } |
| 190 | 411 | |
| 191 | 412 | /** |
| 413 | + * Apply the declared POS variation sorts to the SQL clauses. | |
| 414 | + * | |
| 415 | + * `posts_clauses` fires for EVERY WP_Query, so the body is guarded by post type and by | |
| 416 | + * the plan itself — it contributes nothing unless this request claimed one of the | |
| 417 | + * declared sorts. | |
| 418 | + * | |
| 419 | + * @param array $clauses Associative array of the clauses for the query. | |
| 420 | + * @param WP_Query $wp_query The WP_Query instance. | |
| 421 | + * | |
| 422 | + * @deprecated Collection Rules now installs this behavior; retained for Pro callers. | |
| 423 | + * @return array | |
| 424 | + */ | |
| 425 | + public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { | |
| 426 | + if ( null === $this->wcpos_sort_request ) { | |
| 427 | + return $clauses; | |
| 428 | + } | |
| 429 | + | |
| 430 | + $post_type = $wp_query->query_vars['post_type'] ?? null; | |
| 431 | + if ( 'product_variation' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'product_variation', $post_type, true ) ) ) { | |
| 432 | + return $clauses; | |
| 433 | + } | |
| 434 | + | |
| 435 | + $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 436 | + | |
| 437 | + return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); | |
| 438 | + } | |
| 439 | + | |
| 440 | + /** | |
| 441 | + * WooCommerce's collection params, plus the sort keys the POS grids offer. | |
| 442 | + * | |
| 443 | + * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on | |
| 444 | + * these four — otherwise the request 400s during argument validation and the switch is dead | |
| 445 | + * code. 1.9.x extended the same enum for the same reason. | |
| 446 | + */ | |
| 447 | + public function get_collection_params() { | |
| 448 | + $params = parent::get_collection_params(); | |
| 449 | + $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; | |
| 450 | + | |
| 451 | + if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { | |
| 452 | + $params['orderby']['enum'] = array_values( | |
| 453 | + array_unique( | |
| 454 | + array_merge( | |
| 455 | + $params['orderby']['enum'], | |
| 456 | + Collection_Rules::orderby_enum( 'variations' ) | |
| 457 | + ) | |
| 458 | + ) | |
| 459 | + ); | |
| 460 | + } | |
| 461 | + | |
| 462 | + return $params; | |
| 463 | + } | |
| 464 | + | |
| 465 | + /** | |
| 466 | + * De-duplicate variation searches joined through matching meta rows. | |
| 467 | + * | |
| 468 | + * @param string $groupby Existing GROUP BY clause. | |
| 469 | + * @param WP_Query $query Query being filtered. | |
| 470 | + * | |
| 471 | + * @deprecated Collection Rules owns variation grouping. | |
| 472 | + */ | |
| 473 | + public function group_search_results( string $groupby, WP_Query $query ): string { | |
| 474 | + return Product_Search::variation_groupby( $groupby, $query->query_vars ); | |
| 475 | + } | |
| 476 | + | |
| 477 | + /** | |
| 478 | + * Does this discovery request still carry a term after normalization? | |
| 479 | + * | |
| 480 | + * `has_param()` is what selects discovery mode, and an empty or whitespace-only value passes | |
| 481 | + * it. This is the check that decides whether a query would actually be constrained. | |
| 482 | + */ | |
| 483 | + private function has_discovery_constraint( WP_REST_Request $request ): bool { | |
| 484 | + $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); | |
| 485 | + if ( '' !== trim( $sku, " \t\n\r\0\x0B," ) ) { | |
| 486 | + return true; | |
| 487 | + } | |
| 488 | + | |
| 489 | + $search = (string) ( $request->get_param( 'search' ) ?? '' ); | |
| 490 | + $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 491 | + | |
| 492 | + return array() !== $terms; | |
| 493 | + } | |
| 494 | + | |
| 495 | + /** | |
| 496 | + * One page of the POS-servable variation collection, with its total. | |
| 497 | + * | |
| 498 | + * WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that | |
| 499 | + * a bare collection request carries no discovery constraint to normalize away, so the | |
| 500 | + * blank-scan guard that turns `?search=%20` into zero rows must NOT apply here. Visibility and | |
| 501 | + * `post_status` narrowing both ride `prepare_objects_query()`, so this page counts exactly what | |
| 502 | + * the client is allowed to receive. | |
| 503 | + * | |
| 504 | + * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} | |
| 505 | + */ | |
| 506 | + private function collection_page( WP_REST_Request $request ): array { | |
| 507 | + $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); | |
| 508 | + $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); | |
| 509 | + $request->set_param( 'per_page', $per_page ); | |
| 510 | + $request->set_param( 'page', $page ); | |
| 511 | + | |
| 512 | + $results = $this->get_objects( $this->prepare_objects_query( $request ) ); | |
| 513 | + | |
| 514 | + $ids = array(); | |
| 515 | + foreach ( $results['objects'] as $object ) { | |
| 516 | + if ( $object instanceof WC_Product_Variation ) { | |
| 517 | + $ids[] = $object->get_id(); | |
| 518 | + } | |
| 519 | + } | |
| 520 | + | |
| 521 | + return array( | |
| 522 | + $ids, | |
| 523 | + array( | |
| 524 | + 'total' => (int) ( $results['total'] ?? \count( $ids ) ), | |
| 525 | + 'page' => $page, | |
| 526 | + 'per_page' => $per_page, | |
| 527 | + ), | |
| 528 | + ); | |
| 529 | + } | |
| 530 | + | |
| 531 | + /** | |
| 192 | 532 | * Discover a page of published, POS-visible variation ids by SKU/barcode. |
| 533 | + * | |
| 534 | + * The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its | |
| 535 | + * own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta` | |
| 536 | + * INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT) | |
| 537 | + * query for the total, and the hidden-id exclusion spliced into the same placeholder list. All | |
| 538 | + * of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later. | |
| 539 | + * | |
| 540 | + * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} | |
| 193 | 541 | */ |
| 194 | 542 | private function search_variation_ids( WP_REST_Request $request ): array { |
| 195 | - global $wpdb; | |
| 196 | - | |
| 197 | 543 | $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); |
| 198 | 544 | $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); |
| 199 | - $args = array( 'product_variation', 'publish' ); | |
| 545 | + $request->set_param( 'per_page', $per_page ); | |
| 546 | + $request->set_param( 'page', $page ); | |
| 200 | 547 | |
| 201 | - if ( $request->has_param( 'sku' ) ) { | |
| 202 | - $skus = array_values( | |
| 203 | - array_filter( | |
| 204 | - array_map( 'trim', explode( ',', (string) $request->get_param( 'sku' ) ) ), | |
| 205 | - static function ( string $sku ): bool { | |
| 206 | - return '' !== $sku; | |
| 207 | - } | |
| 208 | - ) | |
| 548 | + $query_args = $this->prepare_objects_query( $request ); | |
| 549 | + | |
| 550 | + /* | |
| 551 | + * A discovery request whose terms normalize away — `?sku=`, `?sku=,%20`, `?search=%20` — | |
| 552 | + * has no constraint left. Inherited, that query would return the FIRST PAGE OF EVERY | |
| 553 | + * VARIATION and advertise the catalogue-wide total; the replaced SQL deliberately used | |
| 554 | + * `1 = 0`. A blank scan must hydrate nothing, not everything. | |
| 555 | + */ | |
| 556 | + if ( ! $this->has_discovery_constraint( $request ) ) { | |
| 557 | + return array( | |
| 558 | + array(), | |
| 559 | + array( | |
| 560 | + 'total' => 0, | |
| 561 | + 'page' => $page, | |
| 562 | + 'per_page' => $per_page, | |
| 563 | + ), | |
| 209 | 564 | ); |
| 210 | - if ( array() === $skus ) { | |
| 211 | - $match_sql = '1 = 0'; | |
| 212 | - } else { | |
| 213 | - $match_sql = 'pm.meta_key = %s AND pm.meta_value IN (' . implode( ',', array_fill( 0, \count( $skus ), '%s' ) ) . ')'; | |
| 214 | - $args[] = '_sku'; | |
| 215 | - $args = array_merge( $args, $skus ); | |
| 216 | - } | |
| 217 | - } else { | |
| 218 | - $terms = preg_split( '/\s+/', trim( (string) $request->get_param( 'search' ) ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 219 | - $fields = Barcode_Field::search_keys(); | |
| 220 | - $matches = array(); | |
| 221 | - foreach ( $terms as $term ) { | |
| 222 | - foreach ( $fields as $field ) { | |
| 223 | - $matches[] = '(pm.meta_key = %s AND pm.meta_value LIKE %s)'; | |
| 224 | - $args[] = $field; | |
| 225 | - $args[] = '%' . $wpdb->esc_like( $term ) . '%'; | |
| 226 | - } | |
| 227 | - } | |
| 228 | - $match_sql = array() === $matches ? '1 = 0' : '(' . implode( ' OR ', $matches ) . ')'; | |
| 229 | 565 | } |
| 230 | 566 | |
| 231 | - $where_sql = " FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID" | |
| 232 | - . ' WHERE p.post_type = %s AND p.post_status = %s AND (' . $match_sql . ')'; | |
| 233 | - // This fragment is prepared once at the end with the rest of $args, so the ids ride the same | |
| 234 | - // placeholder list rather than going through Pos_Visibility::apply_to_sql_where(). | |
| 235 | - $hidden = ( new Pos_Visibility() )->hidden_ids( Pos_Visibility::VARIATIONS ); | |
| 236 | - if ( array() !== $hidden ) { | |
| 237 | - $where_sql .= ' AND p.ID NOT IN (' . implode( ',', array_fill( 0, \count( $hidden ), '%d' ) ) . ')'; | |
| 238 | - $args = array_merge( $args, $hidden ); | |
| 567 | + $results = $this->get_objects( $query_args ); | |
| 568 | + | |
| 569 | + $ids = array(); | |
| 570 | + foreach ( $results['objects'] as $object ) { | |
| 571 | + if ( $object instanceof WC_Product_Variation ) { | |
| 572 | + $ids[] = $object->get_id(); | |
| 573 | + } | |
| 239 | 574 | } |
| 240 | 575 | |
| 241 | - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- SQL fragments are fixed; all values use placeholders. | |
| 242 | - $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT p.ID)' . $where_sql, $args ) ); | |
| 243 | - $ids = $wpdb->get_col( | |
| 244 | - $wpdb->prepare( | |
| 245 | - 'SELECT DISTINCT p.ID' . $where_sql . ' ORDER BY p.ID DESC LIMIT %d OFFSET %d', | |
| 246 | - array_merge( $args, array( $per_page, ( $page - 1 ) * $per_page ) ) | |
| 247 | - ) | |
| 248 | - ); | |
| 249 | - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared | |
| 250 | - | |
| 251 | 576 | return array( |
| 252 | - array_map( 'intval', $ids ), | |
| 577 | + $ids, | |
| 253 | 578 | array( |
| 254 | - 'total' => $total, | |
| 579 | + 'total' => (int) ( $results['total'] ?? \count( $ids ) ), | |
| 255 | 580 | 'page' => $page, |
| 256 | 581 | 'per_page' => $per_page, |
| 257 | 582 | ), |
| 258 | 583 | ); |