PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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
woocommerce-pos / includes / API / V2 / Variations_Controller.php

Variations_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/API/V2/Variations_Controller.php

261 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 WCPOS\WooCommercePOS\Services\Barcode_Field;
12 use WCPOS\WooCommercePOS\Sync\Api;
13 use WCPOS\WooCommercePOS\Sync\Digest_Index;
14 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
15 use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
16 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
17 use WP_Error;
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Server;
21
22 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
23
24 /**
25 * Variations document endpoint (on-demand variation fetch).
26 *
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.
34 */
35 class Variations_Controller extends WP_REST_Controller {
36 use Endpoint_Permissions;
37
38 private const MAX_SKU_LENGTH = 4096;
39 private const MAX_SKU_TERMS = 100;
40 private const MAX_SEARCH_LENGTH = 256;
41 private const MAX_SEARCH_TERMS = 10;
42 private const MAX_PAGE = 1000;
43
44
45 public function register_routes(): void {
46 register_rest_route(
47 Api::ROUTE_NAMESPACE,
48 '/variations',
49 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 ),
65 ),
66 )
67 );
68 }
69
70 /**
71 * GET /variations?include=12,34,56 — hydrate the given variation ids.
72 *
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).
77 */
78 public function get_variations( WP_REST_Request $request ) {
79 $started = microtime( true );
80 $search_meta = null;
81 if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) {
82 $validation = $this->validate_search_request( $request );
83 if ( is_wp_error( $validation ) ) {
84 return $validation;
85 }
86 list( $ids, $search_meta ) = $this->search_variation_ids( $request );
87 } 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 ) );
91 }
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 );
96 }
97 _prime_post_caches( $ids, true, true );
98
99 // Hydrate through THE product assembly line (Product_Serializer), the same
100 // seam resolve/changes use (ADR 0003 — values come from the REST
101 // representation, never raw SQL). wc_get_product() returns a
102 // WC_Product_Variation for a variation id; the instanceof guard keeps a
103 // product id from being hydrated through this lane.
104 // Leg-3 (ADR 0014): attach each variation's stored 64-bit digest as `_rxdb_digest` so the client
105 // seeds its existence-reconcile manifest from this pull too (products get theirs via the proxy
106 // filter). Bulk-read once for the whole include set. A string — the digest exceeds int range.
107 // ::class, never the bare string: from inside this namespace
108 // class_exists( 'Digest_Index' ) probes the GLOBAL namespace and is
109 // forever false, so variation digests would never emit (review finding 3).
110 // Variations read the PRODUCTS id-space — one registry row owns both
111 // object types, so this lane cannot drift from the proxy lane's answer.
112 $digests = class_exists( Digest_Index::class )
113 ? ( new Digest_Index() )->read_digests( 'products', $ids )
114 : array();
115
116 $serialization_request = new WP_REST_Request( 'GET', '/' );
117 $serializer = new Product_Serializer();
118 $documents = array();
119 foreach ( $ids as $id ) {
120 $variation = wc_get_product( $id );
121 if ( ! $variation instanceof WC_Product_Variation ) {
122 continue;
123 }
124 $payload = $serializer->serialize( $variation, $serialization_request );
125 $document = array(
126 'id' => $id,
127 'parent_id' => (int) $variation->get_parent_id(),
128 'payload' => $payload,
129 );
130 if ( isset( $digests[ $id ] ) ) {
131 $document['_rxdb_digest'] = $digests[ $id ];
132 }
133 $documents[] = $document;
134 }
135
136 $meta = array(
137 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
138 'requested' => \count( $ids ),
139 'returned' => \count( $documents ),
140 );
141 if ( null !== $search_meta ) {
142 $meta = array_merge( $meta, $search_meta );
143 }
144
145 return rest_ensure_response(
146 array(
147 'documents' => $documents,
148 'meta' => $meta,
149 )
150 );
151 }
152
153 /**
154 * Reject search requests that could build excessively large SQL queries or offsets.
155 *
156 * @return true|WP_Error
157 */
158 private function validate_search_request( WP_REST_Request $request ) {
159 if ( $request->has_param( 'sku' ) ) {
160 $sku = (string) $request->get_param( 'sku' );
161 if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) {
162 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) );
163 }
164 $skus = array_filter(
165 array_map( 'trim', explode( ',', $sku ) ),
166 static function ( string $term ): bool {
167 return '' !== $term;
168 }
169 );
170 if ( self::MAX_SKU_TERMS < \count( $skus ) ) {
171 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) );
172 }
173 } else {
174 $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 ) );
177 }
178 $terms = (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY );
179 if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) {
180 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) );
181 }
182 }
183
184 if ( self::MAX_PAGE < (int) $request->get_param( 'page' ) ) {
185 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'page must not exceed 1000', array( 'status' => 400 ) );
186 }
187
188 return true;
189 }
190
191 /**
192 * Discover a page of published, POS-visible variation ids by SKU/barcode.
193 */
194 private function search_variation_ids( WP_REST_Request $request ): array {
195 global $wpdb;
196
197 $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) );
198 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
199 $args = array( 'product_variation', 'publish' );
200
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 )
209 );
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 }
230
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 );
239 }
240
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 return array(
252 array_map( 'intval', $ids ),
253 array(
254 'total' => $total,
255 'page' => $page,
256 'per_page' => $per_page,
257 ),
258 );
259 }
260 }
261