PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/API/V2/Variations_Controller.php +430 -105 1.10.01.10.19 View file →
@@ -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 );