PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.9
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.9
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 / Sync / Proxy_Uuid_Stamper.php

Proxy_Uuid_Stamper.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.9, at includes/Sync/Proxy_Uuid_Stamper.php

335 lines 12.4 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\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use Exception;
11 use WC_Coupon;
12 use WC_Customer;
13
14 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
15 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholder lists are generated from sanitized integer IDs.
16
17 /**
18 * Stamps stable record UUIDs onto catalog-proxy responses.
19 */
20 final class Proxy_Uuid_Stamper {
21 /**
22 * Registered proxy callbacks.
23 *
24 * @var array<int, callable>
25 */
26 private static $proxy_stampers = array();
27
28 /**
29 * Registry-driven stamper config (#421 increment 3): build the
30 * stamp_proxy_generic config for one collection FROM ITS REGISTRY ROW —
31 * the per-collection mapping (which resources, which bulk reader, which
32 * detector, which loader) collapses into Collections; the
33 * loader-KIND switch below is the irreducible remainder (five loader
34 * strategies, not nine collection cases). Returns null when the
35 * collection has no identity or no proxy (nothing to stamp).
36 */
37 public static function proxy_stamper_config( string $collection ): ?array {
38 $row = Collections::row( $collection );
39 if ( ! isset( $row['identity'], $row['proxy'] ) ) {
40 return null;
41 }
42 $identity = $row['identity'];
43
44 return array(
45 'resources' => array( $row['proxy']['slug'] ),
46 'bulk_read' => null === $identity['bulk_reader'] ? null : array( self::class, $identity['bulk_reader'] ),
47 'load' => self::loader_for( $identity['loader'] ),
48 'collides' => array( Pos_Uuid::class, $identity['detector'] ),
49 );
50 }
51
52 /**
53 * Register one uuid stamper per identity-and-proxy registry row on the
54 * catalog-proxy response filter. Replaces plugin.php's hand-maintained
55 * list — adding a collection means adding ONE registry row. Returns the
56 * registered collection names (the wiring golden pins them).
57 */
58 public static function register_proxy_stampers(): array {
59 $registered = array();
60 foreach ( array_keys( Collections::with( 'identity' ) ) as $collection ) {
61 $config = self::proxy_stamper_config( $collection );
62 if ( null === $config ) {
63 continue;
64 }
65 $callback = static function ( $data, $resource = '', $request = null ) use ( $config ) {
66 return self::stamp_proxy_generic( $data, $resource, $config );
67 };
68 add_filter(
69 'woocommerce_pos_sync_proxy_response',
70 $callback,
71 10,
72 3
73 );
74 self::$proxy_stampers[] = $callback;
75 $registered[] = $collection;
76 }
77
78 return $registered;
79 }
80
81 /**
82 * Remove every registered UUID proxy stamper.
83 */
84 public static function unregister_proxy_stampers(): void {
85 foreach ( self::$proxy_stampers as $callback ) {
86 remove_filter( 'woocommerce_pos_sync_proxy_response', $callback, 10 );
87 }
88 self::$proxy_stampers = array();
89 }
90
91 /**
92 * Read `_woocommerce_pos_uuid` for many post ids in ONE query — returns
93 * post_id => uuid for the valid ones (malformed/blank skipped). Empty when $wpdb or
94 * the id set is unavailable (then callers fall back to per-object minting).
95 */
96 public static function bulk_read_post_uuids( array $ids ): array {
97 global $wpdb;
98 if ( ! isset( $wpdb ) || empty( $ids ) ) {
99 return array();
100 }
101 $ids = array_values( array_unique( array_map( 'intval', $ids ) ) );
102 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
103 // ORDER BY meta_id ASC + first-valid-wins below: if a post carries DUPLICATE uuid
104 // metas (a concurrent first-stamp before prune converges them), pick the SAME
105 // canonical one read_valid_uuid_from_meta / prune_duplicate_uuid_meta keep — the
106 // earliest valid entry — so the served identity is deterministic, not row-order luck.
107 $sql = $wpdb->prepare(
108 "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ($placeholders) ORDER BY meta_id ASC",
109 array_merge( array( Pos_Uuid::META_KEY ), $ids )
110 );
111 $out = array();
112 foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $row ) {
113 $pid = (int) ( \is_array( $row ) ? ( $row['post_id'] ?? 0 ) : 0 );
114 $value = \is_array( $row ) ? ( $row['meta_value'] ?? '' ) : '';
115 if ( ! isset( $out[ $pid ] ) && Pos_Uuid::is_uuid( $value ) ) {
116 $out[ $pid ] = $value; // keep the FIRST valid uuid per post (canonical)
117 }
118 }
119
120 return $out;
121 }
122
123 /**
124 * User-table twin of bulk_read_post_uuids for CUSTOMERS — reads existing
125 * uuids from wp_usermeta in ONE query. umeta_id ASC + first-valid-wins keeps
126 * the SAME canonical entry read_valid_uuid_from_meta would, so a customer with
127 * duplicate uuid metas serves a deterministic identity, not row-order luck.
128 */
129 public static function bulk_read_user_uuids( array $ids ): array {
130 global $wpdb;
131 if ( ! isset( $wpdb ) || empty( $ids ) ) {
132 return array();
133 }
134 $ids = array_values( array_unique( array_map( 'intval', $ids ) ) );
135 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
136 $sql = $wpdb->prepare(
137 "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s AND user_id IN ($placeholders) ORDER BY umeta_id ASC",
138 array_merge( array( Pos_Uuid::META_KEY ), $ids )
139 );
140 $out = array();
141 foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $row ) {
142 $uid = (int) ( \is_array( $row ) ? ( $row['user_id'] ?? 0 ) : 0 );
143 $value = \is_array( $row ) ? ( $row['meta_value'] ?? '' ) : '';
144 if ( ! isset( $out[ $uid ] ) && Pos_Uuid::is_uuid( $value ) ) {
145 $out[ $uid ] = $value; // keep the FIRST valid uuid per user (canonical)
146 }
147 }
148
149 return $out;
150 }
151
152 /**
153 * Term-table twin for CATEGORIES + BRANDS — reads existing uuids from
154 * wp_termmeta in ONE query (meta_id ASC, first-valid-wins canonical). One read
155 * serves a whole proxy page; categories and brands share the table so the same
156 * helper feeds both term resources.
157 */
158 public static function bulk_read_term_uuids( array $ids ): array {
159 global $wpdb;
160 if ( ! isset( $wpdb ) || empty( $ids ) ) {
161 return array();
162 }
163 $ids = array_values( array_unique( array_map( 'intval', $ids ) ) );
164 $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
165 $sql = $wpdb->prepare(
166 "SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id IN ($placeholders) ORDER BY meta_id ASC",
167 array_merge( array( Pos_Uuid::META_KEY ), $ids )
168 );
169 $out = array();
170 foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $row ) {
171 $tid = (int) ( \is_array( $row ) ? ( $row['term_id'] ?? 0 ) : 0 );
172 $value = \is_array( $row ) ? ( $row['meta_value'] ?? '' ) : '';
173 if ( ! isset( $out[ $tid ] ) && Pos_Uuid::is_uuid( $value ) ) {
174 $out[ $tid ] = $value; // keep the FIRST valid uuid per term (canonical)
175 }
176 }
177
178 return $out;
179 }
180 /**
181 * The ONE stamping loop behind every stamp_proxy_* collection hook on the
182 * `woocommerce_pos_sync_proxy_response` filter. The catalog proxy forwards wc/v3 LISTS,
183 * but wc/v3 STRIPS the protected `_woocommerce_pos_uuid` from most read payloads —
184 * so without these stampers the served records carry no identity and the
185 * uuid-native client (identifyRecord, mintOnMissing:false) throws. A collection
186 * differs ONLY by its config row (mirroring the backfill controller's entity-kind
187 * dispatch — reader / loader / detector per meta store):
188 *
189 * - 'resources' string[]: the proxy resources the row serves.
190 * - 'bulk_read' ?callable(int[] $ids): id => uuid — existing uuids read in ONE
191 * meta-table query (the stamped fast path: one query per page, not N object
192 * loads), then re-INJECTED into each payload (wc/v3 stripped them). null =
193 * PAYLOAD mode (orders): existing uuids are read from each served record's own
194 * meta_data (HPOS wc/v3 exposes the meta; orders aren't posts, so there is no
195 * postmeta to bulk-read), and a record that is stamped AND unique passes
196 * through UNTOUCHED — no injection, no meta_data reshuffle.
197 * - 'load' callable(int $id): the record's stampable object (the WC_Data meta
198 * duck-type ensure_uuid consumes) or null — loaded ONLY for unstamped/collided
199 * records, which ensure_uuid mints/re-keys + persists.
200 * - 'collides' callable: the collection's ownership detector (ensure_uuid's
201 * 'collides' opt) — posts / users / terms / HPOS orders each query their own
202 * store, with deliberately different scoping (see each detector's docblock).
203 *
204 * A uuid shared by MORE THAN ONE record in this response is a clone/import that
205 * copied the protected meta — emitting it would give two RxDB records the SAME
206 * primary key (one hiding the other on the client). Those are routed through
207 * ensure_uuid so the detector re-keys the duplicate, exactly as the
208 * write-time/backfill path does — never injected off the fast bulk path.
209 * Cross-response clones remain the backfill's collision-repair job
210 * (/uuid/backfill?mode=collisions).
211 *
212 * @param mixed $data
213 * @param mixed $resource
214 */
215 private static function stamp_proxy_generic( $data, $resource, array $config ) {
216 if ( ! \in_array( $resource, $config['resources'], true ) || ! \is_array( $data ) ) {
217 return $data;
218 }
219 $payload_mode = null === $config['bulk_read'];
220 $existing = array();
221 if ( $payload_mode ) {
222 // Count EVERY served uuid toward collision detection — including one on a
223 // record with no id (it still reaches the client, so a uuid it shares must
224 // re-key the id-bearing holder).
225 $seen = array();
226 foreach ( $data as $record ) {
227 if ( \is_array( $record ) ) {
228 $u = Pos_Uuid::read_valid_uuid_from_meta( $record['meta_data'] ?? array() );
229 if ( '' !== $u ) {
230 $seen[] = $u;
231 }
232 }
233 }
234 } else {
235 $ids = array();
236 foreach ( $data as $record ) {
237 if ( \is_array( $record ) && isset( $record['id'] ) ) {
238 $ids[] = (int) $record['id'];
239 }
240 }
241 if ( empty( $ids ) ) {
242 return $data;
243 }
244 $existing = $config['bulk_read']( $ids );
245 $seen = $existing;
246 }
247 /** @var array<int, string> $seen */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- narrow the bulk_read closure return for PHPStan.
248 $collision = array_fill_keys(
249 array_keys(
250 array_filter(
251 array_count_values( $seen ),
252 static function ( $count ) {
253 return $count > 1; }
254 )
255 ),
256 true
257 );
258 foreach ( $data as $index => $record ) {
259 if ( ! \is_array( $record ) || ! isset( $record['id'] ) ) {
260 continue;
261 }
262 $uuid = $payload_mode
263 ? Pos_Uuid::read_valid_uuid_from_meta( $record['meta_data'] ?? array() )
264 : ( $existing[ (int) $record['id'] ] ?? '' );
265 if ( '' !== $uuid && isset( $collision[ $uuid ] ) ) {
266 $uuid = ''; // in-response collision — re-key below, don't emit a duplicate key
267 }
268 if ( '' !== $uuid && $payload_mode ) {
269 continue; // stamped and unique — the served meta_data already carries it
270 }
271 if ( '' === $uuid ) {
272 $object = $config['load']( (int) $record['id'] ); // unstamped or collided — mint/re-key + persist
273 if ( $object ) {
274 $uuid = Pos_Uuid::ensure_uuid( $object, array( 'collides' => $config['collides'] ) );
275 }
276 }
277 if ( '' !== $uuid ) {
278 $data[ $index ] = Pos_Uuid::ensure_in_payload( $record, $uuid );
279 }
280 }
281
282 return $data;
283 }
284
285 /**
286 * The five loader strategies (kind-level — see proxy_stamper_config).
287 */
288 private static function loader_for( string $loader ): callable {
289 switch ( $loader ) {
290 case 'coupon':
291 return static function ( int $id ) {
292 if ( ! class_exists( 'WC_Coupon' ) ) {
293 return null;
294 }
295 $coupon = new WC_Coupon( $id );
296
297 return $coupon->get_id() ? $coupon : null;
298 };
299 case 'customer':
300 return static function ( int $id ) {
301 if ( ! class_exists( 'WC_Customer' ) ) {
302 return null;
303 }
304
305 try {
306 $customer = new WC_Customer( $id );
307 } catch ( Exception $e ) {
308 return null;
309 }
310
311 // Guard the round-trip: a WC_Customer for a missing id can construct empty.
312 return ( method_exists( $customer, 'get_id' ) && (int) $customer->get_id() === $id ) ? $customer : null;
313 };
314 case 'term':
315 return static function ( int $id ) {
316 // The adapter presents the WC_Data meta contract over wp_termmeta.
317 return new Term_Meta_Adapter( $id );
318 };
319 case 'order':
320 return static function ( int $id ) {
321 $order = \function_exists( 'wc_get_order' ) ? wc_get_order( $id ) : false;
322
323 return $order ? $order : null;
324 };
325 case 'product':
326 default:
327 return static function ( int $id ) {
328 $product = \function_exists( 'wc_get_product' ) ? wc_get_product( $id ) : false;
329
330 return $product ? $product : null;
331 };
332 }
333 }
334 }
335