/variations`). One flat route * IS the cross-parent collection: bare pages seed the complete replica (the idle trickle), * `include=` is one filter on it (targeted hydration, no parent->child dance), and the * SKU/barcode discovery search is another. * * Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed. * WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in * the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its * own collection params. 1.9.x did exactly this: `parent::get_items( $request )`, one line * (`API\V1\Product_Variations_Controller::wcpos_get_all_items`). * * The previous version of this class extended a bare `WP_REST_Controller` and rebuilt the query * by hand — ~90 lines of raw postmeta SQL, five hand-declared args, no item schema — on the * stated grounds that "wc/v3 has no cross-parent variations?include=". That claim was false, and * the cost of acting on it was the payload: a variation hydrated through the PRODUCTS controller * carries `images[]` instead of `image`, which blanked every variation thumbnail in the POS on * 1.10.0 and wrote the parent's image onto every order line (#1710). * * What stays ours, and only this: the sync document envelope the engine reads * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier * search, and the request bounds. Everything else is WooCommerce's. */ class Variations_Controller extends WC_REST_Product_Variations_Controller { /** * Request keys the variation Collection Rules plan reads on this lane. * * @var array */ private const WCPOS_SORT_PARAM_MAP = array( 'orderby' => 'orderby', 'order' => 'order', ); /** * The request whose declared sort `wcpos_posts_clauses()` applies. * * @var null|WP_REST_Request */ private $wcpos_sort_request = null; use Endpoint_Permissions; private const MAX_SKU_LENGTH = 4096; private const MAX_SKU_TERMS = 100; private const MAX_SEARCH_LENGTH = 256; private const MAX_SEARCH_TERMS = 10; private const MAX_PAGE = 1000; public function register_routes(): void { /* * ONLY the flat sync route. `parent::register_routes()` is deliberately not called: the * v2 namespace is a read/sync surface, and writes ride Write_Controller, which already * pushes through WooCommerce's nested routes. Registering WC's CRUD routes here would * widen the POS-marker-gated surface for no consumer. * * The args and the schema are WooCommerce's own, so `include`, `search`, `orderby`, * `order`, `offset`, `page`, `per_page`, `status` … all behave exactly as they do on * wc/v3, and the route documents itself in the REST index. */ register_rest_route( Api::ROUTE_NAMESPACE, '/variations', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_variations' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Narrow WooCommerce's variation query to what the POS may serve. * * Everything WooCommerce already understands — `include`, `offset`, `order`, pagination, * status — comes from `parent::prepare_objects_query()`, which also applies * `woocommerce_rest_product_variation_object_query` internally (wc/v3's CRUD controller fires * it there, not in `get_items()`), so third-party query scoping reaches every lane built * through this method. Layered on top — deliberately AFTER that filter, so a third party * cannot widen what the POS may serve: POS visibility, the * barcode-carrier search, and the sort keys the POS grids offer. This is the seam 1.9.x used * for the same job (`API\V1\Product_Variations_Controller::prepare_objects_query`). * * @param WP_REST_Request $request Full details about the request. * * @return array */ protected function prepare_objects_query( $request ) { /* * WooCommerce splits `sku` on commas without trimming, so `sku=A, B` looks for " B". * Normalize before it sees the param rather than reimplementing its matching. */ $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); if ( '' !== $sku ) { $terms = array_values( array_filter( array_map( 'trim', explode( ',', $sku ) ), static function ( string $term ): bool { return '' !== $term; } ) ); $sku = implode( ',', $terms ); $request->set_param( 'sku', $sku ); } $args = parent::prepare_objects_query( $request ); /* * A product is not a variation document. * * WooCommerce widens `post_type` to `array( 'product', 'product_variation' )` whenever * `sku` is set, because the two share one SKU space. On THIS route that would serve a * simple product as a variation — and the client would file it into its variations * collection, the mirror image of the misfiled-variation pollution it already carries a * one-shot repair for. Type purity on a variations route is ours to enforce. */ $args['post_type'] = $this->post_type; /* * `search` means the barcode CARRIERS here, not the post title. * * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a * variation, whose title is a generated attribute string. The POS searches what a cashier * actually types or scans: the SKU and whichever meta key the store configured as its * barcode field (`Barcode_Field::search_keys()`). Every term must match at least one carrier. * * `sku` is left to WooCommerce: its own exact/comma-list handling is what the * sku-beats-search precedence rule relies on. */ $search = (string) ( $request->get_param( 'search' ) ?? '' ); if ( '' !== $sku ) { // SKU is an exact lookup and outranks a fuzzy one; leaving WooCommerce's post-title // `s` in place would AND the two and return nothing. unset( $args['s'] ); } if ( '' !== $search && '' === $sku ) { unset( $args['s'] ); $args['wcpos_variation_search'] = true; $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); if ( false === $terms ) { $terms = array(); } $carriers = array( 'relation' => 'AND' ); foreach ( $terms as $term ) { $term_carriers = array( 'relation' => 'OR' ); foreach ( Barcode_Field::search_keys() as $key ) { $term_carriers[] = array( 'key' => $key, 'value' => $term, 'compare' => 'LIKE', ); } $carriers[] = $term_carriers; } if ( 1 < \count( $carriers ) ) { $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } } /* * This route only ever offers what the store owner has for sale — on EVERY lane, including * `include`. * * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private` * when unchecked ({@see \WC_Meta_Box_Product_Data::save_variations()}), and WooCommerce * honours that everywhere a customer can reach: `get_visible_children()` and * `get_available_variations()` both drop it. A cashier must not be able to sell a variation * the owner switched off, so the POS behaves the same way. * * The `include` lane is NOT exempt. Being asked for an id by name is not evidence the owner * wants it sold: the client learns those ids from the parent's `variations[]`, which * WooCommerce fills from `get_children()` — publish AND private — and from the change * signal, which journals a disabled variation like any other post. A disabled id simply is * not hydrated, the client's targeted-pull shortfall prunes it, and it leaves every till. * Re-enabling saves the variation, which journals it, and it comes back. * * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it. */ $args['post_status'] = 'publish'; /* * Leg-3 (ADR 0014 WP-M5): POS-hidden (`online_only`) variations are never served. As a * query exclusion rather than a post-hoc filter of the result, so paging and totals count * the same set the client is allowed to see. * * Through the helper, NOT a raw `post__not_in` merge: `parent::prepare_objects_query()` * maps `include` to `post__in`, and WP_Query IGNORES `post__not_in` when `post__in` is * present — so `?search=X&include=` would have served a hidden variation. * `apply_to_wp_query_args()` already owns that trap: it intersects `post__in` with the * hidden set and pins an empty intersection to `array( 0 )`. */ $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); /* * The POS sorts on fields WooCommerce does not offer as orderby values. They are * declared in Sync\Collection_Rules and projected into get_collection_params() * below — without that, `orderby=sku` is rejected by REST argument validation * before anything here runs. * * They are applied as SQL clauses, NOT as `meta_key` + `orderby => meta_value`: * that pair INNER JOINs postmeta and drops every variation with no value for the * key, so the sort silently filtered. `wcpos_posts_clauses()` LEFT JOINs instead * and orders the meta-less rows last. */ $this->wcpos_sort_request = $request; add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); return $args; } /** * GET /variations — the flat collection's three lanes, one response shape. * * `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one * collection page (the trickle's seed lane); `?include=12,34` hydrates the * named ids. All three resolve ids through WooCommerce's collection query, * then hydrate through the shared assembly line below. Mirrors the wc/v3 * `products?include=` shape; the parent is resolved server-side off the * loaded variation object (get_parent_id), so the client never needs to know * parents. Unknown / non-variation ids are skipped (deletes are handled by * the change-signal tombstone path, not here). */ public function get_variations( WP_REST_Request $request ) { $started = microtime( true ); $search_meta = null; if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) { $validation = $this->validate_search_request( $request ); if ( is_wp_error( $validation ) ) { return $validation; } list( $ids, $search_meta ) = $this->search_variation_ids( $request ); } elseif ( array() === array_filter( (array) $request->get_param( 'include' ) ) ) { /* * A bare collection request — no `include`, no discovery term — answers page one of the * POS-servable set with WooCommerce's own pagination, exactly as its `get_items()` would. * * This used to be a 400. That refusal is why the client still counts variations on the * FROZEN `wcpos/v1` lane — the single remaining v1 call in the app — because the census * probes a collection route and reads `X-WP-Total`, and no v2 variations route could * answer "how many". Refusing the question was never a safety property: `include` is a * filter, and a collection route with no filter is a collection. */ list( $ids, $search_meta ) = $this->collection_page( $request ); } else { /* * The ask runs through the SAME query WooCommerce's own collection read builds * (#1751): `parent::prepare_objects_query()` maps `include` to `post__in` and — in * wc/v3's CRUD controller — applies `woocommerce_rest_product_variation_object_query` * internally, so third-party query scoping reaches this lane like every other * (hook-parity contract #1738). The collection and discovery lanes always had that * property; this lane loaded ids directly and bypassed it. POS visibility and the * publish gate ride the same args (layered in our override). * * The paging/ordering params are PINNED, not honoured: this lane answers a named * ask, so `per_page` covers the whole ask, `offset`/`page` cannot skip any of it * (a skipped id is absent from documents, which the client reads as "prune this * id"), and `orderby=include` keeps WooCommerce from ordering by a meta key whose * EXISTS join would silently drop every variation lacking that meta row. Pinning * `orderby` also keeps the args complete for direct (non-dispatched) invocations, * which carry no route defaults. Served order is the include order either way — * the intersect below is the final authority. */ $include_ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); // Pins live on a QUERY-ONLY clone: the dispatched request stays exactly // as the client sent it, for the serializer's prepare-filters and for // anything downstream reading it after dispatch. $query_request = clone $request; $query_request->set_param( 'per_page', max( 1, count( $include_ids ) ) ); $query_request->set_param( 'page', 1 ); $query_request->set_param( 'offset', 0 ); $query_request->set_param( 'orderby', 'include' ); $query_request->set_param( 'order', 'asc' ); $args = $this->prepare_objects_query( $query_request ); /* * The ask is a CEILING. WooCommerce's variations controller UNIONS some * collection params into `post__in` (`on_sale=true` array-unions every on-sale * id on top of the ask), so without this intersection a stray param would * hydrate the whole store into the till. No request param or filter may widen * the served set beyond the named ids — narrowing is fine, that is what the * object_query filter and the visibility exclusion are for. An emptied ask pins * to `array( 0 )`, the same never-matches sentinel Pos_Visibility uses. */ $post_in = array_values( array_intersect( array_map( 'intval', (array) ( $args['post__in'] ?? array() ) ), $include_ids ) ); $args['post__in'] = array() === $post_in ? array( 0 ) : $post_in; $results = $this->get_objects( $args ); $allowed_ids = array(); foreach ( $results['objects'] as $object ) { if ( $object instanceof WC_Product_Variation ) { $allowed_ids[] = $object->get_id(); } } $ids = array_values( array_intersect( $include_ids, $allowed_ids ) ); } _prime_post_caches( $ids, true, true ); // Hydrate through THE product assembly line (Product_Serializer), the same // seam resolve/changes use (ADR 0003 — values come from the REST // representation, never raw SQL). wc_get_product() returns a // WC_Product_Variation for a variation id; the instanceof guard keeps a // product id from being hydrated through this lane. // Leg-3 (ADR 0014): attach each variation's stored 64-bit digest as `_rxdb_digest` so the client // seeds its existence-reconcile manifest from this pull too (products get theirs via the proxy // filter). Bulk-read once for the whole include set. A string — the digest exceeds int range. // ::class, never the bare string: from inside this namespace // class_exists( 'Digest_Index' ) probes the GLOBAL namespace and is // forever false, so variation digests would never emit (review finding 3). // Variations read the PRODUCTS id-space — one registry row owns both // object types, so this lane cannot drift from the proxy lane's answer. $digests = class_exists( Digest_Index::class ) ? ( new Digest_Index() )->read_digests( 'products', $ids ) : array(); $serializer = new Product_Serializer(); // A CLONE of the live request, not a synthetic bare one (so prepare-filters // see the real request context), and not the live request itself (the // serializer stamps store scope and a per-variation `product_id` onto // whatever it is handed; the dispatched request must leave this method as // the client sent it). $serialization_request = clone $request; $documents = array(); foreach ( $ids as $id ) { $variation = wc_get_product( $id ); if ( ! $variation instanceof WC_Product_Variation ) { continue; } /* * DISABLED variations are never hydrated — see the `post_status` note in * prepare_objects_query(). The query-level publish gate covers ALL lanes, including * `include`; this check only guards a status change between the id query and object load. * * `meta.requested` now counts the query-eligible ask: a disabled or query-filtered id is * absent from $ids. The client's targeted-pull shortfall — absence from documents — is * unchanged. */ if ( 'publish' !== $variation->get_status() ) { continue; } $payload = $serializer->serialize( $variation, $serialization_request ); $document = array( 'id' => $id, 'parent_id' => (int) $variation->get_parent_id(), 'payload' => $payload, ); if ( isset( $digests[ $id ] ) ) { $document['_rxdb_digest'] = $digests[ $id ]; } $documents[] = $document; } $meta = array( 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ), 'requested' => \count( $ids ), 'returned' => \count( $documents ), ); if ( null !== $search_meta ) { $meta = array_merge( $meta, $search_meta ); } $response = rest_ensure_response( array( 'documents' => $documents, 'meta' => $meta, ) ); /* * The pagination WooCommerce would have sent. * * No v2 route emitted `X-WP-Total`/`X-WP-TotalPages` — including this one, the only one * that paginates. The client asks for them on every v2 GET (the response envelope mirrors * them into the body), so it has been receiving an empty mirror and falling back to * short-page detection, which cannot tell "last page" from "the server truncated". */ if ( null !== $search_meta && $response instanceof WP_REST_Response ) { $response->header( 'X-WP-Total', (string) $search_meta['total'] ); $response->header( 'X-WP-TotalPages', (string) ( $search_meta['per_page'] > 0 ? (int) ceil( $search_meta['total'] / $search_meta['per_page'] ) : 0 ) ); } return $response; } /** * Reject search requests that could build excessively large SQL queries or offsets. * * @return true|WP_Error */ private function validate_search_request( WP_REST_Request $request ) { $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); $skus = array_filter( array_map( 'trim', explode( ',', $sku ) ), static function ( string $term ): bool { return '' !== $term; } ); if ( array() !== $skus ) { $sku = implode( ',', $skus ); if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) { return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) ); } if ( self::MAX_SKU_TERMS < \count( $skus ) ) { return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) ); } } else { $search = (string) $request->get_param( 'search' ); // Unlike mb_strlen(), PCRE is independent of blog_charset and detects malformed UTF-8. $characters = preg_match_all( '/./us', $search ); if ( false === $characters ) { return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); } if ( self::MAX_SEARCH_LENGTH < $characters ) { return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 characters', array( 'status' => 400 ) ); } $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); if ( false === $terms ) { return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); } if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); } } if ( self::MAX_PAGE < (int) $request->get_param( 'page' ) ) { return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'page must not exceed 1000', array( 'status' => 400 ) ); } return true; } /** * Apply the declared POS variation sorts to the SQL clauses. * * `posts_clauses` fires for EVERY WP_Query, so the body is guarded by post type and by * the plan itself — it contributes nothing unless this request claimed one of the * declared sorts. * * @param array $clauses Associative array of the clauses for the query. * @param WP_Query $wp_query The WP_Query instance. * * @return array */ public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { if ( null === $this->wcpos_sort_request ) { return $clauses; } $post_type = $wp_query->query_vars['post_type'] ?? null; if ( 'product_variation' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'product_variation', $post_type, true ) ) ) { return $clauses; } $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); } /** * WooCommerce's collection params, plus the sort keys the POS grids offer. * * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on * these four — otherwise the request 400s during argument validation and the switch is dead * code. 1.9.x extended the same enum for the same reason. */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { $params['orderby']['enum'] = array_values( array_unique( array_merge( $params['orderby']['enum'], Collection_Rules::orderby_enum( 'variations' ) ) ) ); } return $params; } /** * De-duplicate variation searches joined through matching meta rows. * * @param string $groupby Existing GROUP BY clause. * @param WP_Query $query Query being filtered. */ public function group_search_results( string $groupby, WP_Query $query ): string { global $wpdb; return ! empty( $query->query_vars['wcpos_variation_search'] ) ? "{$wpdb->posts}.ID" : $groupby; } /** * Does this discovery request still carry a term after normalization? * * `has_param()` is what selects discovery mode, and an empty or whitespace-only value passes * it. This is the check that decides whether a query would actually be constrained. */ private function has_discovery_constraint( WP_REST_Request $request ): bool { $sku = (string) ( $request->get_param( 'sku' ) ?? '' ); if ( '' !== trim( $sku, " \t\n\r\0\x0B," ) ) { return true; } $search = (string) ( $request->get_param( 'search' ) ?? '' ); $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); if ( false === $terms ) { return false; } return array() !== $terms; } /** * One page of the POS-servable variation collection, with its total. * * WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that * a bare collection request carries no discovery constraint to normalize away, so the * blank-scan guard that turns `?search=%20` into zero rows must NOT apply here. Visibility and * `post_status` narrowing both ride `prepare_objects_query()`, so this page counts exactly what * the client is allowed to receive. * * @return array{0: array, 1: array{total: int, page: int, per_page: int}} */ private function collection_page( WP_REST_Request $request ): array { $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $request->set_param( 'per_page', $per_page ); $request->set_param( 'page', $page ); $results = $this->get_objects( $this->prepare_objects_query( $request ) ); $ids = array(); foreach ( $results['objects'] as $object ) { if ( $object instanceof WC_Product_Variation ) { $ids[] = $object->get_id(); } } return array( $ids, array( 'total' => (int) ( $results['total'] ?? \count( $ids ) ), 'page' => $page, 'per_page' => $per_page, ), ); } /** * Discover a page of published, POS-visible variation ids by SKU/barcode. * * The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its * own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta` * INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT) * query for the total, and the hidden-id exclusion spliced into the same placeholder list. All * of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later. * * @return array{0: array, 1: array{total: int, page: int, per_page: int}} */ private function search_variation_ids( WP_REST_Request $request ): array { $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $request->set_param( 'per_page', $per_page ); $request->set_param( 'page', $page ); $query_args = $this->prepare_objects_query( $request ); /* * A discovery request whose terms normalize away — `?sku=`, `?sku=,%20`, `?search=%20` — * has no constraint left. Inherited, that query would return the FIRST PAGE OF EVERY * VARIATION and advertise the catalogue-wide total; the replaced SQL deliberately used * `1 = 0`. A blank scan must hydrate nothing, not everything. */ if ( ! $this->has_discovery_constraint( $request ) ) { return array( array(), array( 'total' => 0, 'page' => $page, 'per_page' => $per_page, ), ); } add_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10, 2 ); try { $results = $this->get_objects( $query_args ); } finally { remove_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10 ); } $ids = array(); foreach ( $results['objects'] as $object ) { if ( $object instanceof WC_Product_Variation ) { $ids[] = $object->get_id(); } } return array( $ids, array( 'total' => (int) ( $results['total'] ?? \count( $ids ) ), 'page' => $page, 'per_page' => $per_page, ), ); } }