PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API / V2 / Uuid_Backfill_Controller.php

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

430 lines 14.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * UUID backfill and collision repair endpoint.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use Automattic\WooCommerce\Utilities\OrderUtil;
11 use Exception;
12 use WC_Coupon;
13 use WC_Customer;
14 use WCPOS\WooCommercePOS\Sync\Api;
15 use WCPOS\WooCommercePOS\Sync\Collections;
16 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
17 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
18 use WCPOS\WooCommercePOS\Sync\Term_Meta_Adapter;
19 use WP_REST_Controller;
20 use WP_REST_Request;
21 use WP_REST_Server;
22
23 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
24 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Queries use internal table names and generated SQL fragments.
25
26 /**
27 * Paginated, idempotent UUID backfill and collision repair.
28 *
29 * A collection's entity kind selects its meta store, object loader, and
30 * collision detector. Calls advance by numeric ID until `complete` is true.
31 */
32 final class Uuid_Backfill_Controller extends WP_REST_Controller {
33 use Endpoint_Permissions;
34
35 /** Well-formed UUID SQL pattern; no request input is interpolated. */
36 private const UUID_SQL_REGEXP = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
37
38 public function register_routes(): void {
39 register_rest_route(
40 Api::ROUTE_NAMESPACE,
41 '/uuid/backfill',
42 array(
43 'methods' => WP_REST_Server::CREATABLE,
44 'callback' => array( $this, 'backfill' ),
45 'permission_callback' => array( $this, 'admin_permissions_check' ),
46 'args' => array(
47 'collection' => array(
48 'default' => 'products',
49 'sanitize_callback' => 'sanitize_text_field',
50 ),
51 'limit' => array(
52 'default' => 100,
53 'sanitize_callback' => 'absint',
54 ),
55 'since_id' => array(
56 'default' => 0,
57 'sanitize_callback' => 'absint',
58 ),
59 'mode' => array(
60 'default' => 'missing',
61 'sanitize_callback' => 'sanitize_text_field',
62 ),
63 ),
64 )
65 );
66 }
67
68 /** Return the meta-store kind for a collection, or an empty string. */
69 public function entity_kind( string $collection ): string {
70 $row = Collections::row( $collection );
71
72 return isset( $row['backfill'] ) ? $row['backfill']['kind'] : '';
73 }
74
75 /** Return the post types scanned by a post-backed collection. */
76 public function post_types_for( string $collection ): array {
77 $row = Collections::row( $collection );
78
79 return $row['backfill']['scan_post_types'] ?? array();
80 }
81
82 /** Return the taxonomy scanned by a term-backed collection. */
83 public function taxonomy_for( string $collection ): string {
84 $row = Collections::row( $collection );
85
86 return $row['backfill']['taxonomy'] ?? '';
87 }
88
89 /** Normalize unknown modes to the idempotent missing scan. */
90 public function normalize_mode( string $mode ): string {
91 return 'collisions' === $mode ? 'collisions' : 'missing';
92 }
93
94 /**
95 * Stamp one page of missing or colliding UUIDs.
96 *
97 * @return array<string, int|string|bool>
98 */
99 public function backfill( WP_REST_Request $request ): array {
100 $collection = (string) $request->get_param( 'collection' );
101 $mode = $this->normalize_mode( (string) $request->get_param( 'mode' ) );
102 $limit = max( 1, min( 500, (int) $request->get_param( 'limit' ) ) );
103 $since_id = max( 0, (int) $request->get_param( 'since_id' ) );
104 $kind = $this->entity_kind( $collection );
105
106 if ( '' === $kind || ! isset( $GLOBALS['wpdb'] ) ) {
107 return $this->response( $collection, $mode, 0, 0, 0, $since_id, true, false );
108 }
109
110 $ids = $this->select_ids( $kind, $collection, $mode, $since_id, $limit );
111 $detector = array( 'collides' => $this->collision_detector( $kind ) );
112 $stamped = 0;
113 $skipped = 0;
114 $cursor = $since_id;
115
116 foreach ( $ids as $id ) {
117 $cursor = $id;
118 $entity = $this->load_entity( $kind, $id );
119 if ( ! $entity ) {
120 ++$skipped;
121 continue;
122 }
123
124 $before = Pos_Uuid::read_valid_uuid_from_meta( (array) $entity->get_meta_data() );
125 $after = Pos_Uuid::ensure_uuid( $entity, $detector );
126 if ( '' !== $after && ( '' === $before || $before !== $after ) ) {
127 ++$stamped;
128 } else {
129 ++$skipped;
130 }
131 }
132
133 return $this->response(
134 $collection,
135 $mode,
136 \count( $ids ),
137 $stamped,
138 $skipped,
139 $cursor,
140 \count( $ids ) < $limit,
141 true
142 );
143 }
144
145 /** Build the stable response contract shared by both modes. */
146 private function response(
147 string $collection,
148 string $mode,
149 int $scanned,
150 int $stamped,
151 int $skipped,
152 int $next_since_id,
153 bool $complete,
154 bool $supported
155 ): array {
156 return array(
157 'collection' => $collection,
158 'mode' => $mode,
159 'scanned' => $scanned,
160 'stamped' => $stamped,
161 'skipped' => $skipped,
162 'next_since_id' => $next_since_id,
163 'complete' => $complete,
164 'supported' => $supported,
165 );
166 }
167
168 /** Load a WC_Data-compatible entity for the selected kind and ID. */
169 private function load_entity( string $kind, int $id ) {
170 switch ( $kind ) {
171 case 'post':
172 if ( 'shop_coupon' === get_post_type( $id ) ) {
173 $coupon = new WC_Coupon( $id );
174
175 return $id === (int) $coupon->get_id() ? $coupon : null;
176 }
177
178 return wc_get_product( $id );
179 case 'order':
180 $order = wc_get_order( $id );
181
182 return $order && $id === (int) $order->get_id() ? $order : null;
183 case 'user':
184 try {
185 $customer = new WC_Customer( $id );
186 } catch ( Exception $exception ) {
187 return null;
188 }
189
190 return $id === (int) $customer->get_id() ? $customer : null;
191 case 'term':
192 return new Term_Meta_Adapter( $id );
193 default:
194 return null;
195 }
196 }
197
198 /** Return the ownership detector for a meta store. */
199 private function collision_detector( string $kind ): array {
200 switch ( $kind ) {
201 case 'order':
202 return array( Pos_Uuid::class, 'uuid_owned_by_other_order' );
203 case 'user':
204 return array( Pos_Uuid::class, 'uuid_owned_by_other_user' );
205 case 'term':
206 return array( Pos_Uuid::class, 'uuid_owned_by_other_term' );
207 case 'post':
208 default:
209 return array( Pos_Uuid::class, 'uuid_owned_by_other' );
210 }
211 }
212
213 /** Dispatch candidate selection to the collection's native store. */
214 private function select_ids( string $kind, string $collection, string $mode, int $since_id, int $limit ): array {
215 switch ( $kind ) {
216 case 'post':
217 return $this->select_ids_post( $this->post_types_for( $collection ), $mode, $since_id, $limit );
218 case 'order':
219 return $this->select_ids_order( $mode, $since_id, $limit );
220 case 'user':
221 return $this->select_ids_user( $mode, $since_id, $limit );
222 case 'term':
223 return $this->select_ids_term( $this->taxonomy_for( $collection ), $mode, $since_id, $limit );
224 default:
225 return array();
226 }
227 }
228
229 /** Select post-backed records with missing or duplicated valid UUID meta. */
230 private function select_ids_post( array $post_types, string $mode, int $since_id, int $limit ): array {
231 global $wpdb;
232 if ( array() === $post_types ) {
233 return array();
234 }
235
236 $type_slots = implode( ',', array_fill( 0, \count( $post_types ), '%s' ) );
237 $regexp = self::UUID_SQL_REGEXP;
238 if ( 'collisions' === $mode ) {
239 $sql = $wpdb->prepare(
240 "SELECT m.post_id FROM {$wpdb->postmeta} m
241 JOIN {$wpdb->posts} p ON p.ID = m.post_id
242 JOIN (
243 SELECT m2.meta_value, MIN(m2.post_id) AS keep_id
244 FROM {$wpdb->postmeta} m2
245 JOIN {$wpdb->posts} p2 ON p2.ID = m2.post_id
246 WHERE m2.meta_key = %s AND m2.meta_value REGEXP '$regexp'
247 AND p2.post_status NOT IN ('trash','auto-draft')
248 GROUP BY m2.meta_value HAVING COUNT(*) > 1
249 ) dups ON dups.meta_value = m.meta_value
250 WHERE m.meta_key = %s AND m.post_id <> dups.keep_id
251 AND p.post_type IN ($type_slots)
252 AND p.post_status NOT IN ('trash','auto-draft')
253 AND m.post_id > %d ORDER BY m.post_id ASC LIMIT %d",
254 array_merge( array( Api::UUID_META_KEY, Api::UUID_META_KEY ), $post_types, array( $since_id, $limit ) )
255 );
256 } else {
257 $sql = $wpdb->prepare(
258 "SELECT p.ID FROM {$wpdb->posts} p
259 LEFT JOIN {$wpdb->postmeta} m
260 ON m.post_id = p.ID AND m.meta_key = %s AND m.meta_value REGEXP '$regexp'
261 WHERE p.post_type IN ($type_slots)
262 AND p.post_status NOT IN ('trash','auto-draft')
263 AND p.ID > %d AND m.meta_id IS NULL
264 ORDER BY p.ID ASC LIMIT %d",
265 array_merge( array( Api::UUID_META_KEY ), $post_types, array( $since_id, $limit ) )
266 );
267 }
268
269 return array_map( 'intval', (array) $wpdb->get_col( $sql ) );
270 }
271
272 /** Select orders from the active HPOS or CPT datastore. */
273 private function select_ids_order( string $mode, int $since_id, int $limit ): array {
274 global $wpdb;
275 $hpos = class_exists( OrderUtil::class )
276 && method_exists( OrderUtil::class, 'custom_orders_table_usage_is_enabled' )
277 && OrderUtil::custom_orders_table_usage_is_enabled();
278 $regexp = self::UUID_SQL_REGEXP;
279
280 if ( $hpos ) {
281 $orders = $wpdb->prefix . 'wc_orders';
282 $meta = $wpdb->prefix . 'wc_orders_meta';
283 if ( 'collisions' === $mode ) {
284 $sql = $wpdb->prepare(
285 "SELECT m.order_id FROM {$meta} m
286 JOIN {$orders} o ON o.id = m.order_id AND o.type = 'shop_order'
287 JOIN (
288 SELECT m2.meta_value, MIN(m2.order_id) AS keep_id
289 FROM {$meta} m2
290 JOIN {$orders} o2 ON o2.id = m2.order_id AND o2.type = 'shop_order'
291 WHERE m2.meta_key = %s AND m2.meta_value REGEXP '$regexp'
292 AND o2.status NOT IN ('trash','auto-draft')
293 GROUP BY m2.meta_value HAVING COUNT(*) > 1
294 ) dups ON dups.meta_value = m.meta_value
295 WHERE m.meta_key = %s AND m.order_id <> dups.keep_id
296 AND o.status NOT IN ('trash','auto-draft')
297 AND m.order_id > %d ORDER BY m.order_id ASC LIMIT %d",
298 Api::UUID_META_KEY,
299 Api::UUID_META_KEY,
300 $since_id,
301 $limit
302 );
303 } else {
304 $sql = $wpdb->prepare(
305 "SELECT o.id FROM {$orders} o
306 LEFT JOIN {$meta} m
307 ON m.order_id = o.id AND m.meta_key = %s AND m.meta_value REGEXP '$regexp'
308 WHERE o.type = 'shop_order' AND o.status NOT IN ('trash','auto-draft')
309 AND o.id > %d AND m.id IS NULL ORDER BY o.id ASC LIMIT %d",
310 Api::UUID_META_KEY,
311 $since_id,
312 $limit
313 );
314 }
315 } elseif ( 'collisions' === $mode ) {
316 $sql = $wpdb->prepare(
317 "SELECT m.post_id FROM {$wpdb->postmeta} m
318 JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type = 'shop_order'
319 JOIN (
320 SELECT m2.meta_value, MIN(m2.post_id) AS keep_id
321 FROM {$wpdb->postmeta} m2
322 JOIN {$wpdb->posts} p2 ON p2.ID = m2.post_id AND p2.post_type = 'shop_order'
323 WHERE m2.meta_key = %s AND m2.meta_value REGEXP '$regexp'
324 AND p2.post_status NOT IN ('trash','auto-draft')
325 GROUP BY m2.meta_value HAVING COUNT(*) > 1
326 ) dups ON dups.meta_value = m.meta_value
327 WHERE m.meta_key = %s AND m.post_id <> dups.keep_id
328 AND p.post_status NOT IN ('trash','auto-draft')
329 AND m.post_id > %d ORDER BY m.post_id ASC LIMIT %d",
330 Api::UUID_META_KEY,
331 Api::UUID_META_KEY,
332 $since_id,
333 $limit
334 );
335 } else {
336 $sql = $wpdb->prepare(
337 "SELECT p.ID FROM {$wpdb->posts} p
338 LEFT JOIN {$wpdb->postmeta} m
339 ON m.post_id = p.ID AND m.meta_key = %s AND m.meta_value REGEXP '$regexp'
340 WHERE p.post_type = 'shop_order' AND p.post_status NOT IN ('trash','auto-draft')
341 AND p.ID > %d AND m.meta_id IS NULL ORDER BY p.ID ASC LIMIT %d",
342 Api::UUID_META_KEY,
343 $since_id,
344 $limit
345 );
346 }
347
348 return array_map( 'intval', (array) $wpdb->get_col( $sql ) );
349 }
350
351 /** Select all WordPress users with missing or duplicated valid UUID meta. */
352 private function select_ids_user( string $mode, int $since_id, int $limit ): array {
353 global $wpdb;
354 $regexp = self::UUID_SQL_REGEXP;
355 if ( 'collisions' === $mode ) {
356 $sql = $wpdb->prepare(
357 "SELECT m.user_id FROM {$wpdb->usermeta} m
358 JOIN {$wpdb->users} u ON u.ID = m.user_id
359 JOIN (
360 SELECT m2.meta_value, MIN(u2.ID) AS keep_id
361 FROM {$wpdb->users} u2
362 JOIN {$wpdb->usermeta} m2 ON m2.user_id = u2.ID
363 WHERE m2.meta_key = %s AND m2.meta_value REGEXP '$regexp'
364 GROUP BY m2.meta_value HAVING COUNT(*) > 1
365 ) dups ON dups.meta_value = m.meta_value
366 WHERE m.meta_key = %s AND m.user_id <> dups.keep_id
367 AND m.user_id > %d ORDER BY m.user_id ASC LIMIT %d",
368 Api::UUID_META_KEY,
369 Api::UUID_META_KEY,
370 $since_id,
371 $limit
372 );
373 } else {
374 $sql = $wpdb->prepare(
375 "SELECT u.ID FROM {$wpdb->users} u
376 LEFT JOIN {$wpdb->usermeta} m
377 ON m.user_id = u.ID AND m.meta_key = %s AND m.meta_value REGEXP '$regexp'
378 WHERE u.ID > %d AND m.umeta_id IS NULL ORDER BY u.ID ASC LIMIT %d",
379 Api::UUID_META_KEY,
380 $since_id,
381 $limit
382 );
383 }
384
385 return array_map( 'intval', (array) $wpdb->get_col( $sql ) );
386 }
387
388 /** Select taxonomy terms with missing or cross-taxonomy duplicate UUIDs. */
389 private function select_ids_term( string $taxonomy, string $mode, int $since_id, int $limit ): array {
390 global $wpdb;
391 if ( '' === $taxonomy ) {
392 return array();
393 }
394 $regexp = self::UUID_SQL_REGEXP;
395 if ( 'collisions' === $mode ) {
396 $sql = $wpdb->prepare(
397 "SELECT m.term_id FROM {$wpdb->termmeta} m
398 JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = m.term_id AND tt.taxonomy = %s
399 JOIN (
400 SELECT m2.meta_value, MIN(m2.term_id) AS keep_id
401 FROM {$wpdb->termmeta} m2
402 WHERE m2.meta_key = %s AND m2.meta_value REGEXP '$regexp'
403 GROUP BY m2.meta_value HAVING COUNT(*) > 1
404 ) dups ON dups.meta_value = m.meta_value
405 WHERE m.meta_key = %s AND m.term_id <> dups.keep_id
406 AND m.term_id > %d ORDER BY m.term_id ASC LIMIT %d",
407 $taxonomy,
408 Api::UUID_META_KEY,
409 Api::UUID_META_KEY,
410 $since_id,
411 $limit
412 );
413 } else {
414 $sql = $wpdb->prepare(
415 "SELECT t.term_id FROM {$wpdb->terms} t
416 JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id AND tt.taxonomy = %s
417 LEFT JOIN {$wpdb->termmeta} m
418 ON m.term_id = t.term_id AND m.meta_key = %s AND m.meta_value REGEXP '$regexp'
419 WHERE t.term_id > %d AND m.meta_id IS NULL ORDER BY t.term_id ASC LIMIT %d",
420 $taxonomy,
421 Api::UUID_META_KEY,
422 $since_id,
423 $limit
424 );
425 }
426
427 return array_map( 'intval', (array) $wpdb->get_col( $sql ) );
428 }
429 }
430