| 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 |
|