| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync read surface. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\API\V2 |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\API\V2; |
| 9 |
|
| 10 |
use WC_Product_Variation; |
| 11 |
use WC_REST_Product_Variations_Controller; |
| 12 |
use WCPOS\WooCommercePOS\Services\Barcode_Field; |
| 13 |
use WCPOS\WooCommercePOS\Sync\Api; |
| 14 |
use WCPOS\WooCommercePOS\Sync\Collection_Rules; |
| 15 |
use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; |
| 16 |
use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 17 |
use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 18 |
use WCPOS\WooCommercePOS\Sync\Pos_Visibility; |
| 19 |
use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| 20 |
use WP_Error; |
| 21 |
use WP_Query; |
| 22 |
use WP_REST_Request; |
| 23 |
use WP_REST_Response; |
| 24 |
use WP_REST_Server; |
| 25 |
|
| 26 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 27 |
|
| 28 |
/** |
| 29 |
* Variations document endpoint — the collection's hydration AND list/seed lane (ADR 0034). |
| 30 |
* |
| 31 |
* Why a flat route: the change-signal yields BARE variation ids (no parent), and WooCommerce's |
| 32 |
* only variation routes are parent-mediated (`products/<parent>/variations`). One flat route |
| 33 |
* IS the cross-parent collection: bare pages seed the complete replica (the idle trickle), |
| 34 |
* `include=` is one filter on it (targeted hydration, no parent->child dance), and the |
| 35 |
* SKU/barcode discovery search is another. |
| 36 |
* |
| 37 |
* Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed. |
| 38 |
* WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in |
| 39 |
* the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its |
| 40 |
* own collection params. 1.9.x did exactly this: `parent::get_items( $request )`, one line |
| 41 |
* (`API\V1\Product_Variations_Controller::wcpos_get_all_items`). |
| 42 |
* |
| 43 |
* The previous version of this class extended a bare `WP_REST_Controller` and rebuilt the query |
| 44 |
* by hand — ~90 lines of raw postmeta SQL, five hand-declared args, no item schema — on the |
| 45 |
* stated grounds that "wc/v3 has no cross-parent variations?include=". That claim was false, and |
| 46 |
* the cost of acting on it was the payload: a variation hydrated through the PRODUCTS controller |
| 47 |
* carries `images[]` instead of `image`, which blanked every variation thumbnail in the POS on |
| 48 |
* 1.10.0 and wrote the parent's image onto every order line (#1710). |
| 49 |
* |
| 50 |
* What stays ours, and only this: the sync document envelope the engine reads |
| 51 |
* (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier |
| 52 |
* search, and the request bounds. Everything else is WooCommerce's. |
| 53 |
*/ |
| 54 |
class Variations_Controller extends WC_REST_Product_Variations_Controller { |
| 55 |
/** |
| 56 |
* Request keys the variation Collection Rules plan reads on this lane. |
| 57 |
* |
| 58 |
* @var array |
| 59 |
*/ |
| 60 |
private const WCPOS_SORT_PARAM_MAP = array( |
| 61 |
'orderby' => 'orderby', |
| 62 |
'order' => 'order', |
| 63 |
); |
| 64 |
|
| 65 |
/** |
| 66 |
* The request whose declared sort `wcpos_posts_clauses()` applies. |
| 67 |
* |
| 68 |
* @var null|WP_REST_Request |
| 69 |
*/ |
| 70 |
private $wcpos_sort_request = null; |
| 71 |
|
| 72 |
use Endpoint_Permissions; |
| 73 |
|
| 74 |
private const MAX_SKU_LENGTH = 4096; |
| 75 |
private const MAX_SKU_TERMS = 100; |
| 76 |
private const MAX_SEARCH_LENGTH = 256; |
| 77 |
private const MAX_PAGE = 1000; |
| 78 |
|
| 79 |
|
| 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 |
*/ |
| 91 |
register_rest_route( |
| 92 |
Api::ROUTE_NAMESPACE, |
| 93 |
'/variations', |
| 94 |
array( |
| 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(), |
| 100 |
), |
| 101 |
'schema' => array( $this, 'get_public_item_schema' ), |
| 102 |
) |
| 103 |
); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Narrow WooCommerce's variation query to what the POS may serve. |
| 108 |
* |
| 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 |
| 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 |
* `search` means the barcode CARRIERS here, not the post title. |
| 156 |
* |
| 157 |
* WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a |
| 158 |
* variation, whose title is a generated attribute string. The POS searches what a cashier |
| 159 |
* actually types or scans: the SKU and whichever meta key the store configured as its |
| 160 |
* barcode field (`Barcode_Field::search_keys()`). The complete phrase must match one carrier. |
| 161 |
* |
| 162 |
* `sku` is left to WooCommerce: its own exact/comma-list handling is what the |
| 163 |
* sku-beats-search precedence rule relies on. |
| 164 |
*/ |
| 165 |
$search = (string) ( $request->get_param( 'search' ) ?? '' ); |
| 166 |
if ( '' !== $sku ) { |
| 167 |
// SKU is an exact lookup and outranks a fuzzy one; leaving WooCommerce's post-title |
| 168 |
// `s` in place would AND the two and return nothing. |
| 169 |
unset( $args['s'] ); |
| 170 |
} |
| 171 |
if ( '' !== $search && '' === $sku ) { |
| 172 |
unset( $args['s'] ); |
| 173 |
$args['wcpos_variation_search'] = true; |
| 174 |
$carriers = array( 'relation' => 'OR' ); |
| 175 |
foreach ( Barcode_Field::search_keys() as $key ) { |
| 176 |
$carriers[] = array( |
| 177 |
'key' => $key, |
| 178 |
'value' => trim( $search ), |
| 179 |
'compare' => 'LIKE', |
| 180 |
); |
| 181 |
} |
| 182 |
$args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 183 |
} |
| 184 |
|
| 185 |
/* |
| 186 |
* This route only ever offers what the store owner has for sale — on EVERY lane, including |
| 187 |
* `include`. |
| 188 |
* |
| 189 |
* WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private` |
| 190 |
* when unchecked ({@see \WC_Meta_Box_Product_Data::save_variations()}), and WooCommerce |
| 191 |
* honours that everywhere a customer can reach: `get_visible_children()` and |
| 192 |
* `get_available_variations()` both drop it. A cashier must not be able to sell a variation |
| 193 |
* the owner switched off, so the POS behaves the same way. |
| 194 |
* |
| 195 |
* The `include` lane is NOT exempt. Being asked for an id by name is not evidence the owner |
| 196 |
* wants it sold: the client learns those ids from the parent's `variations[]`, which |
| 197 |
* WooCommerce fills from `get_children()` — publish AND private — and from the change |
| 198 |
* signal, which journals a disabled variation like any other post. A disabled id simply is |
| 199 |
* not hydrated, the client's targeted-pull shortfall prunes it, and it leaves every till. |
| 200 |
* Re-enabling saves the variation, which journals it, and it comes back. |
| 201 |
* |
| 202 |
* Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it. |
| 203 |
*/ |
| 204 |
$args['post_status'] = 'publish'; |
| 205 |
|
| 206 |
/* |
| 207 |
* Leg-3 (ADR 0014 WP-M5): POS-hidden (`online_only`) variations are never served. As a |
| 208 |
* query exclusion rather than a post-hoc filter of the result, so paging and totals count |
| 209 |
* the same set the client is allowed to see. |
| 210 |
* |
| 211 |
* Through the helper, NOT a raw `post__not_in` merge: `parent::prepare_objects_query()` |
| 212 |
* maps `include` to `post__in`, and WP_Query IGNORES `post__not_in` when `post__in` is |
| 213 |
* present — so `?search=X&include=<hidden id>` would have served a hidden variation. |
| 214 |
* `apply_to_wp_query_args()` already owns that trap: it intersects `post__in` with the |
| 215 |
* hidden set and pins an empty intersection to `array( 0 )`. |
| 216 |
*/ |
| 217 |
$args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); |
| 218 |
|
| 219 |
/* |
| 220 |
* The POS sorts on fields WooCommerce does not offer as orderby values. They are |
| 221 |
* declared in Sync\Collection_Rules and projected into get_collection_params() |
| 222 |
* below — without that, `orderby=sku` is rejected by REST argument validation |
| 223 |
* before anything here runs. |
| 224 |
* |
| 225 |
* They are applied as SQL clauses, NOT as `meta_key` + `orderby => meta_value`: |
| 226 |
* that pair INNER JOINs postmeta and drops every variation with no value for the |
| 227 |
* key, so the sort silently filtered. `wcpos_posts_clauses()` LEFT JOINs instead |
| 228 |
* and orders the meta-less rows last. |
| 229 |
*/ |
| 230 |
$this->wcpos_sort_request = $request; |
| 231 |
add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); |
| 232 |
|
| 233 |
return $args; |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* GET /variations — the flat collection's three lanes, one response shape. |
| 238 |
* |
| 239 |
* `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one |
| 240 |
* collection page (the trickle's seed lane); `?include=12,34` hydrates the |
| 241 |
* named ids. All three resolve ids through WooCommerce's collection query, |
| 242 |
* then hydrate through the shared assembly line below. Mirrors the wc/v3 |
| 243 |
* `products?include=` shape; the parent is resolved server-side off the |
| 244 |
* loaded variation object (get_parent_id), so the client never needs to know |
| 245 |
* parents. Unknown / non-variation ids are skipped (deletes are handled by |
| 246 |
* the change-signal tombstone path, not here). |
| 247 |
*/ |
| 248 |
public function get_variations( WP_REST_Request $request ) { |
| 249 |
$started = microtime( true ); |
| 250 |
$search_meta = null; |
| 251 |
if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) { |
| 252 |
$validation = $this->validate_search_request( $request ); |
| 253 |
if ( is_wp_error( $validation ) ) { |
| 254 |
return $validation; |
| 255 |
} |
| 256 |
list( $ids, $search_meta ) = $this->search_variation_ids( $request ); |
| 257 |
} elseif ( array() === array_filter( (array) $request->get_param( 'include' ) ) ) { |
| 258 |
/* |
| 259 |
* A bare collection request — no `include`, no discovery term — answers page one of the |
| 260 |
* POS-servable set with WooCommerce's own pagination, exactly as its `get_items()` would. |
| 261 |
* |
| 262 |
* This used to be a 400. That refusal is why the client still counts variations on the |
| 263 |
* FROZEN `wcpos/v1` lane — the single remaining v1 call in the app — because the census |
| 264 |
* probes a collection route and reads `X-WP-Total`, and no v2 variations route could |
| 265 |
* answer "how many". Refusing the question was never a safety property: `include` is a |
| 266 |
* filter, and a collection route with no filter is a collection. |
| 267 |
*/ |
| 268 |
list( $ids, $search_meta ) = $this->collection_page( $request ); |
| 269 |
} else { |
| 270 |
/* |
| 271 |
* The ask runs through the SAME query WooCommerce's own collection read builds |
| 272 |
* (#1751): `parent::prepare_objects_query()` maps `include` to `post__in` and — in |
| 273 |
* wc/v3's CRUD controller — applies `woocommerce_rest_product_variation_object_query` |
| 274 |
* internally, so third-party query scoping reaches this lane like every other |
| 275 |
* (hook-parity contract #1738). The collection and discovery lanes always had that |
| 276 |
* property; this lane loaded ids directly and bypassed it. POS visibility and the |
| 277 |
* publish gate ride the same args (layered in our override). |
| 278 |
* |
| 279 |
* The paging/ordering params are PINNED, not honoured: this lane answers a named |
| 280 |
* ask, so `per_page` covers the whole ask, `offset`/`page` cannot skip any of it |
| 281 |
* (a skipped id is absent from documents, which the client reads as "prune this |
| 282 |
* id"), and `orderby=include` keeps WooCommerce from ordering by a meta key whose |
| 283 |
* EXISTS join would silently drop every variation lacking that meta row. Pinning |
| 284 |
* `orderby` also keeps the args complete for direct (non-dispatched) invocations, |
| 285 |
* which carry no route defaults. Served order is the include order either way — |
| 286 |
* the intersect below is the final authority. |
| 287 |
*/ |
| 288 |
$include_ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); |
| 289 |
// Pins live on a QUERY-ONLY clone: the dispatched request stays exactly |
| 290 |
// as the client sent it, for the serializer's prepare-filters and for |
| 291 |
// anything downstream reading it after dispatch. |
| 292 |
$query_request = clone $request; |
| 293 |
$query_request->set_param( 'per_page', max( 1, count( $include_ids ) ) ); |
| 294 |
$query_request->set_param( 'page', 1 ); |
| 295 |
$query_request->set_param( 'offset', 0 ); |
| 296 |
$query_request->set_param( 'orderby', 'include' ); |
| 297 |
$query_request->set_param( 'order', 'asc' ); |
| 298 |
$args = $this->prepare_objects_query( $query_request ); |
| 299 |
|
| 300 |
/* |
| 301 |
* The ask is a CEILING. WooCommerce's variations controller UNIONS some |
| 302 |
* collection params into `post__in` (`on_sale=true` array-unions every on-sale |
| 303 |
* id on top of the ask), so without this intersection a stray param would |
| 304 |
* hydrate the whole store into the till. No request param or filter may widen |
| 305 |
* the served set beyond the named ids — narrowing is fine, that is what the |
| 306 |
* object_query filter and the visibility exclusion are for. An emptied ask pins |
| 307 |
* to `array( 0 )`, the same never-matches sentinel Pos_Visibility uses. |
| 308 |
*/ |
| 309 |
$post_in = array_values( array_intersect( array_map( 'intval', (array) ( $args['post__in'] ?? array() ) ), $include_ids ) ); |
| 310 |
$args['post__in'] = array() === $post_in ? array( 0 ) : $post_in; |
| 311 |
$results = $this->get_objects( $args ); |
| 312 |
|
| 313 |
$allowed_ids = array(); |
| 314 |
foreach ( $results['objects'] as $object ) { |
| 315 |
if ( $object instanceof WC_Product_Variation ) { |
| 316 |
$allowed_ids[] = $object->get_id(); |
| 317 |
} |
| 318 |
} |
| 319 |
$ids = array_values( array_intersect( $include_ids, $allowed_ids ) ); |
| 320 |
} |
| 321 |
_prime_post_caches( $ids, true, true ); |
| 322 |
|
| 323 |
// Hydrate through THE product assembly line (Product_Serializer), the same |
| 324 |
// seam resolve/changes use (ADR 0003 — values come from the REST |
| 325 |
// representation, never raw SQL). wc_get_product() returns a |
| 326 |
// WC_Product_Variation for a variation id; the instanceof guard keeps a |
| 327 |
// product id from being hydrated through this lane. |
| 328 |
// Leg-3 (ADR 0014): attach each variation's stored 64-bit digest as `_rxdb_digest` so the client |
| 329 |
// seeds its existence-reconcile manifest from this pull too (products get theirs via the proxy |
| 330 |
// filter). Bulk-read once for the whole include set. A string — the digest exceeds int range. |
| 331 |
// ::class, never the bare string: from inside this namespace |
| 332 |
// class_exists( 'Digest_Index' ) probes the GLOBAL namespace and is |
| 333 |
// forever false, so variation digests would never emit (review finding 3). |
| 334 |
// Variations read the PRODUCTS id-space — one registry row owns both |
| 335 |
// object types, so this lane cannot drift from the proxy lane's answer. |
| 336 |
$digests = class_exists( Digest_Index::class ) |
| 337 |
? ( new Digest_Index() )->read_digests( 'products', $ids ) |
| 338 |
: array(); |
| 339 |
|
| 340 |
$serializer = new Product_Serializer(); |
| 341 |
// A CLONE of the live request, not a synthetic bare one (so prepare-filters |
| 342 |
// see the real request context), and not the live request itself (the |
| 343 |
// serializer stamps store scope and a per-variation `product_id` onto |
| 344 |
// whatever it is handed; the dispatched request must leave this method as |
| 345 |
// the client sent it). |
| 346 |
$serialization_request = clone $request; |
| 347 |
$documents = array(); |
| 348 |
foreach ( $ids as $id ) { |
| 349 |
$variation = wc_get_product( $id ); |
| 350 |
if ( ! $variation instanceof WC_Product_Variation ) { |
| 351 |
continue; |
| 352 |
} |
| 353 |
/* |
| 354 |
* DISABLED variations are never hydrated — see the `post_status` note in |
| 355 |
* prepare_objects_query(). The query-level publish gate covers ALL lanes, including |
| 356 |
* `include`; this check only guards a status change between the id query and object load. |
| 357 |
* |
| 358 |
* `meta.requested` now counts the query-eligible ask: a disabled or query-filtered id is |
| 359 |
* absent from $ids. The client's targeted-pull shortfall — absence from documents — is |
| 360 |
* unchanged. |
| 361 |
*/ |
| 362 |
if ( 'publish' !== $variation->get_status() ) { |
| 363 |
continue; |
| 364 |
} |
| 365 |
$payload = $serializer->serialize( $variation, $serialization_request ); |
| 366 |
$document = array( |
| 367 |
'id' => $id, |
| 368 |
'parent_id' => (int) $variation->get_parent_id(), |
| 369 |
'payload' => $payload, |
| 370 |
); |
| 371 |
if ( isset( $digests[ $id ] ) ) { |
| 372 |
$document['_rxdb_digest'] = $digests[ $id ]; |
| 373 |
} |
| 374 |
$documents[] = $document; |
| 375 |
} |
| 376 |
|
| 377 |
$meta = array( |
| 378 |
'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ), |
| 379 |
'requested' => \count( $ids ), |
| 380 |
'returned' => \count( $documents ), |
| 381 |
); |
| 382 |
if ( null !== $search_meta ) { |
| 383 |
$meta = array_merge( $meta, $search_meta ); |
| 384 |
} |
| 385 |
|
| 386 |
$response = rest_ensure_response( |
| 387 |
array( |
| 388 |
'documents' => $documents, |
| 389 |
'meta' => $meta, |
| 390 |
) |
| 391 |
); |
| 392 |
|
| 393 |
/* |
| 394 |
* The pagination WooCommerce would have sent. |
| 395 |
* |
| 396 |
* No v2 route emitted `X-WP-Total`/`X-WP-TotalPages` — including this one, the only one |
| 397 |
* that paginates. The client asks for them on every v2 GET (the response envelope mirrors |
| 398 |
* them into the body), so it has been receiving an empty mirror and falling back to |
| 399 |
* short-page detection, which cannot tell "last page" from "the server truncated". |
| 400 |
*/ |
| 401 |
if ( null !== $search_meta && $response instanceof WP_REST_Response ) { |
| 402 |
$response->header( 'X-WP-Total', (string) $search_meta['total'] ); |
| 403 |
$response->header( |
| 404 |
'X-WP-TotalPages', |
| 405 |
(string) ( $search_meta['per_page'] > 0 ? (int) ceil( $search_meta['total'] / $search_meta['per_page'] ) : 0 ) |
| 406 |
); |
| 407 |
} |
| 408 |
|
| 409 |
return $response; |
| 410 |
} |
| 411 |
|
| 412 |
/** |
| 413 |
* Reject search requests that could build excessively large SQL queries or offsets. |
| 414 |
* |
| 415 |
* @return true|WP_Error |
| 416 |
*/ |
| 417 |
private function validate_search_request( WP_REST_Request $request ) { |
| 418 |
$sku = (string) ( $request->get_param( 'sku' ) ?? '' ); |
| 419 |
$skus = array_filter( |
| 420 |
array_map( 'trim', explode( ',', $sku ) ), |
| 421 |
static function ( string $term ): bool { |
| 422 |
return '' !== $term; |
| 423 |
} |
| 424 |
); |
| 425 |
if ( array() !== $skus ) { |
| 426 |
$sku = implode( ',', $skus ); |
| 427 |
if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) { |
| 428 |
return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) ); |
| 429 |
} |
| 430 |
if ( self::MAX_SKU_TERMS < \count( $skus ) ) { |
| 431 |
return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) ); |
| 432 |
} |
| 433 |
} else { |
| 434 |
$search = (string) $request->get_param( 'search' ); |
| 435 |
if ( self::MAX_SEARCH_LENGTH < \strlen( $search ) ) { |
| 436 |
return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 bytes', array( 'status' => 400 ) ); |
| 437 |
} |
| 438 |
} |
| 439 |
|
| 440 |
if ( self::MAX_PAGE < (int) $request->get_param( 'page' ) ) { |
| 441 |
return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'page must not exceed 1000', array( 'status' => 400 ) ); |
| 442 |
} |
| 443 |
|
| 444 |
return true; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Apply the declared POS variation sorts to the SQL clauses. |
| 449 |
* |
| 450 |
* `posts_clauses` fires for EVERY WP_Query, so the body is guarded by post type and by |
| 451 |
* the plan itself — it contributes nothing unless this request claimed one of the |
| 452 |
* declared sorts. |
| 453 |
* |
| 454 |
* @param array $clauses Associative array of the clauses for the query. |
| 455 |
* @param WP_Query $wp_query The WP_Query instance. |
| 456 |
* |
| 457 |
* @return array |
| 458 |
*/ |
| 459 |
public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { |
| 460 |
if ( null === $this->wcpos_sort_request ) { |
| 461 |
return $clauses; |
| 462 |
} |
| 463 |
|
| 464 |
$post_type = $wp_query->query_vars['post_type'] ?? null; |
| 465 |
if ( 'product_variation' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'product_variation', $post_type, true ) ) ) { |
| 466 |
return $clauses; |
| 467 |
} |
| 468 |
|
| 469 |
$plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); |
| 470 |
|
| 471 |
return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* WooCommerce's collection params, plus the sort keys the POS grids offer. |
| 476 |
* |
| 477 |
* `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on |
| 478 |
* these four — otherwise the request 400s during argument validation and the switch is dead |
| 479 |
* code. 1.9.x extended the same enum for the same reason. |
| 480 |
*/ |
| 481 |
public function get_collection_params() { |
| 482 |
$params = parent::get_collection_params(); |
| 483 |
$params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; |
| 484 |
|
| 485 |
if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { |
| 486 |
$params['orderby']['enum'] = array_values( |
| 487 |
array_unique( |
| 488 |
array_merge( |
| 489 |
$params['orderby']['enum'], |
| 490 |
Collection_Rules::orderby_enum( 'variations' ) |
| 491 |
) |
| 492 |
) |
| 493 |
); |
| 494 |
} |
| 495 |
|
| 496 |
return $params; |
| 497 |
} |
| 498 |
|
| 499 |
/** |
| 500 |
* De-duplicate variation searches joined through matching meta rows. |
| 501 |
* |
| 502 |
* @param string $groupby Existing GROUP BY clause. |
| 503 |
* @param WP_Query $query Query being filtered. |
| 504 |
*/ |
| 505 |
public function group_search_results( string $groupby, WP_Query $query ): string { |
| 506 |
global $wpdb; |
| 507 |
|
| 508 |
return ! empty( $query->query_vars['wcpos_variation_search'] ) ? "{$wpdb->posts}.ID" : $groupby; |
| 509 |
} |
| 510 |
|
| 511 |
/** |
| 512 |
* Does this discovery request still carry a term after normalization? |
| 513 |
* |
| 514 |
* `has_param()` is what selects discovery mode, and an empty or whitespace-only value passes |
| 515 |
* it. This is the check that decides whether a query would actually be constrained. |
| 516 |
*/ |
| 517 |
private function has_discovery_constraint( WP_REST_Request $request ): bool { |
| 518 |
$sku = (string) ( $request->get_param( 'sku' ) ?? '' ); |
| 519 |
if ( '' !== trim( $sku, " \t\n\r\0\x0B," ) ) { |
| 520 |
return true; |
| 521 |
} |
| 522 |
|
| 523 |
$search = (string) ( $request->get_param( 'search' ) ?? '' ); |
| 524 |
|
| 525 |
return array() !== (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); |
| 526 |
} |
| 527 |
|
| 528 |
/** |
| 529 |
* One page of the POS-servable variation collection, with its total. |
| 530 |
* |
| 531 |
* WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that |
| 532 |
* a bare collection request carries no discovery constraint to normalize away, so the |
| 533 |
* blank-scan guard that turns `?search=%20` into zero rows must NOT apply here. Visibility and |
| 534 |
* `post_status` narrowing both ride `prepare_objects_query()`, so this page counts exactly what |
| 535 |
* the client is allowed to receive. |
| 536 |
* |
| 537 |
* @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} |
| 538 |
*/ |
| 539 |
private function collection_page( WP_REST_Request $request ): array { |
| 540 |
$per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); |
| 541 |
$page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); |
| 542 |
$request->set_param( 'per_page', $per_page ); |
| 543 |
$request->set_param( 'page', $page ); |
| 544 |
|
| 545 |
$results = $this->get_objects( $this->prepare_objects_query( $request ) ); |
| 546 |
|
| 547 |
$ids = array(); |
| 548 |
foreach ( $results['objects'] as $object ) { |
| 549 |
if ( $object instanceof WC_Product_Variation ) { |
| 550 |
$ids[] = $object->get_id(); |
| 551 |
} |
| 552 |
} |
| 553 |
|
| 554 |
return array( |
| 555 |
$ids, |
| 556 |
array( |
| 557 |
'total' => (int) ( $results['total'] ?? \count( $ids ) ), |
| 558 |
'page' => $page, |
| 559 |
'per_page' => $per_page, |
| 560 |
), |
| 561 |
); |
| 562 |
} |
| 563 |
|
| 564 |
/** |
| 565 |
* Discover a page of published, POS-visible variation ids by SKU/barcode. |
| 566 |
* |
| 567 |
* The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its |
| 568 |
* own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta` |
| 569 |
* INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT) |
| 570 |
* query for the total, and the hidden-id exclusion spliced into the same placeholder list. All |
| 571 |
* of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later. |
| 572 |
* |
| 573 |
* @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} |
| 574 |
*/ |
| 575 |
private function search_variation_ids( WP_REST_Request $request ): array { |
| 576 |
$per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); |
| 577 |
$page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); |
| 578 |
$request->set_param( 'per_page', $per_page ); |
| 579 |
$request->set_param( 'page', $page ); |
| 580 |
|
| 581 |
$query_args = $this->prepare_objects_query( $request ); |
| 582 |
|
| 583 |
/* |
| 584 |
* A discovery request whose terms normalize away — `?sku=`, `?sku=,%20`, `?search=%20` — |
| 585 |
* has no constraint left. Inherited, that query would return the FIRST PAGE OF EVERY |
| 586 |
* VARIATION and advertise the catalogue-wide total; the replaced SQL deliberately used |
| 587 |
* `1 = 0`. A blank scan must hydrate nothing, not everything. |
| 588 |
*/ |
| 589 |
if ( ! $this->has_discovery_constraint( $request ) ) { |
| 590 |
return array( |
| 591 |
array(), |
| 592 |
array( |
| 593 |
'total' => 0, |
| 594 |
'page' => $page, |
| 595 |
'per_page' => $per_page, |
| 596 |
), |
| 597 |
); |
| 598 |
} |
| 599 |
|
| 600 |
add_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10, 2 ); |
| 601 |
try { |
| 602 |
$results = $this->get_objects( $query_args ); |
| 603 |
} finally { |
| 604 |
remove_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10 ); |
| 605 |
} |
| 606 |
|
| 607 |
$ids = array(); |
| 608 |
foreach ( $results['objects'] as $object ) { |
| 609 |
if ( $object instanceof WC_Product_Variation ) { |
| 610 |
$ids[] = $object->get_id(); |
| 611 |
} |
| 612 |
} |
| 613 |
|
| 614 |
return array( |
| 615 |
$ids, |
| 616 |
array( |
| 617 |
'total' => (int) ( $results['total'] ?? \count( $ids ) ), |
| 618 |
'page' => $page, |
| 619 |
'per_page' => $per_page, |
| 620 |
), |
| 621 |
); |
| 622 |
} |
| 623 |
} |
| 624 |
|