| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS collection query rules. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
use Automattic\WooCommerce\Utilities\OrderUtil; |
| 11 |
use WCPOS\WooCommercePOS\Services\Barcode_Field; |
| 12 |
use WP_REST_Request; |
| 13 |
|
| 14 |
/** |
| 15 |
* THE declaration table for POS collection query behaviour — one Collection Rule per |
| 16 |
* behaviour, declared once and applied identically on every Read Lane. |
| 17 |
* |
| 18 |
* # The problem this exists to remove |
| 19 |
* |
| 20 |
* A POS query behaviour ("sort orders by cashier-visible payment method", "let |
| 21 |
* `wcpos_include` narrow the result set") used to be written twice: once in the |
| 22 |
* `wcpos/v1` controller that owns the direct lane, and once as a hand-copied mirror |
| 23 |
* inside `V2\Catalog_Proxy_Controller` for the proxy lane. Two encodings of one rule |
| 24 |
* drift — and they had: `payment_method` sorted three different columns across the two |
| 25 |
* lanes and two storages. Every such divergence is a parity bug the client sees as |
| 26 |
* "sorting is wrong on this endpoint". |
| 27 |
* |
| 28 |
* Here, a behaviour is a ROW. Both lanes read the same row, so a lane cannot have a |
| 29 |
* behaviour the other lacks, and the next parity fix is one row plus one pure test. |
| 30 |
* |
| 31 |
* # Surface |
| 32 |
* |
| 33 |
* - `for_request()` — pure, memoized, never null and never throws. An unknown |
| 34 |
* collection yields an EMPTY plan whose `filter()` is the identity and whose |
| 35 |
* `around()` merely runs its callable. That is the adoption mechanism: a collection |
| 36 |
* can be routed through the module before it has any rows, and nothing changes. |
| 37 |
* - `Collection_Rules_Plan::filter()` — the direct lane. Type-preserving clause |
| 38 |
* bodies; it NEVER touches global filter state, so a v1 controller keeps owning its |
| 39 |
* own `add_filter` topology (Pro subclasses those callbacks). |
| 40 |
* - `Collection_Rules_Plan::around()` — the proxy lane, and the ONLY install path. |
| 41 |
* Callbacks are installed, the forward runs, and every binding is unwound in reverse |
| 42 |
* in a `finally`. |
| 43 |
* - `orderby_enum()` / `collection_params()` — schema PROJECTIONS of the same rows, so |
| 44 |
* the REST schema and the proxy's claim list cannot disagree with the clause logic. |
| 45 |
* |
| 46 |
* # Param-map narrowing |
| 47 |
* |
| 48 |
* Each lane passes a canonical-name => request-key map. A canonical name absent from |
| 49 |
* the map is INVISIBLE to the plan. This is how `wcpos/v1` keeps not supporting |
| 50 |
* `created_via` (its historic `@TODO`) while the row exists for the proxy: the omission |
| 51 |
* is a product decision recorded in one place, not an accident of which file was edited. |
| 52 |
* |
| 53 |
* A map entry is either a request key, or an array of: |
| 54 |
* - `key` (string, required) the request key to read. |
| 55 |
* - `when` (string, optional) `'search'` — claim only when the request also carries a |
| 56 |
* non-empty `search`. wc/v3 resolves `search` to a matched-id set that |
| 57 |
* CLOBBERS `include`/`exclude`, so the rule takes ownership of the id sets |
| 58 |
* exactly then; a plain targeted pull keeps wc/v3's native semantics. |
| 59 |
* A WCPOS-private key such as `wcpos_include` needs no such condition — |
| 60 |
* wc/v3 never sees it. |
| 61 |
* - `parse` (string, optional) `'id_list'` runs `wp_parse_id_list` at claim time. |
| 62 |
* The default reproduces `wcpos/v1`'s historic `array_map( 'intval', (array) $v )` |
| 63 |
* cast verbatim (a comma-joined string collapses to its first id) because |
| 64 |
* v1 wire behaviour is frozen. Unifying the two is a follow-up. |
| 65 |
* |
| 66 |
* # Storage |
| 67 |
* |
| 68 |
* Rows carry a sub-array per storage dialect (`hpos` — the `wc_orders` tables, `posts` — |
| 69 |
* the legacy `wp_posts`/`wp_postmeta` pair). The storage is resolved ONCE, at plan |
| 70 |
* construction, so no clause body re-detects it halfway through a query. |
| 71 |
* |
| 72 |
* @see Collection_Rules_Plan for the per-request object. |
| 73 |
*/ |
| 74 |
final class Collection_Rules { |
| 75 |
/** |
| 76 |
* High Performance Order Storage — the `wc_orders` custom tables. |
| 77 |
* |
| 78 |
* @var string |
| 79 |
*/ |
| 80 |
public const STORAGE_HPOS = 'hpos'; |
| 81 |
|
| 82 |
/** |
| 83 |
* Legacy storage — `wp_posts` plus `wp_postmeta`. |
| 84 |
* |
| 85 |
* @var string |
| 86 |
*/ |
| 87 |
public const STORAGE_POSTS = 'posts'; |
| 88 |
|
| 89 |
/** |
| 90 |
* Memoized plans, keyed by collection, request identity, storage and param map. |
| 91 |
* |
| 92 |
* Each entry is `array( WP_REST_Request, Collection_Rules_Plan )`; the request is |
| 93 |
* kept so a recycled `spl_object_id` can never serve another request's plan. |
| 94 |
* |
| 95 |
* @var array<string, array{0: WP_REST_Request, 1: Collection_Rules_Plan}> |
| 96 |
*/ |
| 97 |
private static $plans = array(); |
| 98 |
|
| 99 |
/** |
| 100 |
* Ceiling on the memo table, so a long-running process cannot grow it without bound. |
| 101 |
* |
| 102 |
* @var int |
| 103 |
*/ |
| 104 |
private const PLAN_CACHE_LIMIT = 32; |
| 105 |
|
| 106 |
/** |
| 107 |
* Build (or return the memoized) plan for one collection read. |
| 108 |
* |
| 109 |
* Pure: it reads the request and the declaration rows and nothing else. It never |
| 110 |
* returns null and never throws — an unknown collection is an empty plan. |
| 111 |
* |
| 112 |
* @param string $collection Collection slug, e.g. `orders`. |
| 113 |
* @param WP_REST_Request $request The request whose params the plan claims from. |
| 114 |
* @param array $param_map Canonical name => request key (see class docblock). |
| 115 |
* @param string|null $storage Storage dialect, or null to detect it. |
| 116 |
* |
| 117 |
* @return Collection_Rules_Plan |
| 118 |
*/ |
| 119 |
public static function for_request( string $collection, WP_REST_Request $request, array $param_map = array(), ?string $storage = null ) { |
| 120 |
$storage = $storage ?? self::detect_storage( $collection ); |
| 121 |
$key = $collection . '|' . spl_object_id( $request ) . '|' . $storage . '|' . md5( (string) wp_json_encode( $param_map ) ); |
| 122 |
|
| 123 |
if ( isset( self::$plans[ $key ] ) && self::$plans[ $key ][0] === $request ) { |
| 124 |
return self::$plans[ $key ][1]; |
| 125 |
} |
| 126 |
|
| 127 |
if ( \count( self::$plans ) >= self::PLAN_CACHE_LIMIT ) { |
| 128 |
self::$plans = array(); |
| 129 |
} |
| 130 |
|
| 131 |
$plan = new Collection_Rules_Plan( $collection, self::rules( $collection ), $storage, $request, $param_map ); |
| 132 |
self::$plans[ $key ] = array( $request, $plan ); |
| 133 |
|
| 134 |
return $plan; |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* The `orderby` values this collection adds to the wc/v3 enum. |
| 139 |
* |
| 140 |
* A PROJECTION of the sort rows: the v1 REST schema, the proxy's claim list and the |
| 141 |
* clause bodies all read this, so a sort cannot be advertised without being wired |
| 142 |
* (or wired without being advertised). |
| 143 |
* |
| 144 |
* @param string $collection Collection slug. |
| 145 |
* |
| 146 |
* @return string[] |
| 147 |
*/ |
| 148 |
public static function orderby_enum( string $collection ): array { |
| 149 |
$rules = self::rules( $collection ); |
| 150 |
|
| 151 |
return array_keys( $rules['sorts'] ?? array() ); |
| 152 |
} |
| 153 |
|
| 154 |
/** |
| 155 |
* The extra REST collection params this collection's filter rows require. |
| 156 |
* |
| 157 |
* A PROJECTION of the filter rows, in declaration order, shaped for |
| 158 |
* `WP_REST_Controller::get_collection_params()`. |
| 159 |
* |
| 160 |
* @param string $collection Collection slug. |
| 161 |
* |
| 162 |
* @return array<string, array> |
| 163 |
*/ |
| 164 |
public static function collection_params( string $collection ): array { |
| 165 |
$params = array(); |
| 166 |
|
| 167 |
if ( 'orders' === $collection ) { |
| 168 |
$params['pos_cashier'] = array( |
| 169 |
'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS cashier.', 'woocommerce-pos' ), |
| 170 |
'type' => 'integer', |
| 171 |
'required' => false, |
| 172 |
); |
| 173 |
// @NOTE - this is different to 'store_id' which is the store the request was made from. |
| 174 |
$params['pos_store'] = array( |
| 175 |
'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS store.', 'woocommerce-pos' ), |
| 176 |
'type' => 'integer', |
| 177 |
'required' => false, |
| 178 |
); |
| 179 |
} |
| 180 |
|
| 181 |
return $params; |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* The declaration rows for one collection. |
| 186 |
* |
| 187 |
* Closed table, private to the module in spirit — public only so the plan can be |
| 188 |
* constructed from it and so pure tests can assert the rows without a bootstrap. |
| 189 |
* |
| 190 |
* A sort row MAY be bodiless (`array()`) when the collection's clauses live outside |
| 191 |
* this module — see the `customers` rows. Such a row still projects into |
| 192 |
* `orderby_enum()`, which is the whole point: one list, both lanes. |
| 193 |
* |
| 194 |
* Sort row shape (per storage): |
| 195 |
* - `hpos` => `array( 'column' => <wc_orders column> )` |
| 196 |
* - `posts` => `array( 'posts_orderby' => <wp_posts column> )` for a column the |
| 197 |
* WP_Query `orderby` vocabulary cannot express (rewritten through |
| 198 |
* `posts_orderby`), OR |
| 199 |
* `array( 'meta_key' => ..., 'orderby' => meta_value|meta_value_num )`. |
| 200 |
* - `posts` => `array( 'meta_sort' => array( 'key' => ..., 'numeric' => bool ) )` |
| 201 |
* a postmeta sort that must NOT filter: applied as a LEFT JOIN through |
| 202 |
* `posts_clauses`, with rows that have no value for the key ordered |
| 203 |
* LAST in both directions. Use this for any user-facing column sort — |
| 204 |
* `meta_key`/`orderby` INNER JOINs and silently drops rows. |
| 205 |
* |
| 206 |
* Filter row shape: |
| 207 |
* - `meta` => `array( 'key' => <meta key>, 'storage' => <optional storage lock> )` |
| 208 |
* a `meta_query` row on the WC query args (works on both storages). |
| 209 |
* - `hpos_data` => `array( 'table' => ..., 'column' => ... )` an id subquery against |
| 210 |
* one of HPOS's side tables, for data that is a COLUMN under HPOS |
| 211 |
* and postmeta under legacy. |
| 212 |
* - `id_set` => `array( 'operator' => 'IN'|'NOT IN' )` a raw id set the rule owns |
| 213 |
* outright (see the `when => search` note in the class docblock). |
| 214 |
* - `sanitize` => optional `'key'`, applied to each claimed value. |
| 215 |
* |
| 216 |
* @internal |
| 217 |
* |
| 218 |
* @param string $collection Collection slug. |
| 219 |
* |
| 220 |
* @return array{sorts?: array<string, array>, filters?: array<string, array>} |
| 221 |
*/ |
| 222 |
public static function rules( string $collection ): array { |
| 223 |
$rules = array( |
| 224 |
'orders' => array( |
| 225 |
'sorts' => array( |
| 226 |
'status' => array( |
| 227 |
'hpos' => array( 'column' => 'status' ), |
| 228 |
'posts' => array( 'posts_orderby' => 'post_status' ), |
| 229 |
), |
| 230 |
'customer_id' => array( |
| 231 |
'hpos' => array( 'column' => 'customer_id' ), |
| 232 |
'posts' => array( |
| 233 |
'meta_key' => '_customer_user', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg. |
| 234 |
'orderby' => 'meta_value_num', |
| 235 |
), |
| 236 |
), |
| 237 |
|
| 238 |
/* |
| 239 |
* PARITY PIN: the two storages sort DIFFERENT things and always have. |
| 240 |
* HPOS sorts the gateway id (`wc_orders.payment_method`, e.g. `pos_cash`); |
| 241 |
* legacy sorts the merchant-visible title meta (`_payment_method_title`, |
| 242 |
* e.g. `Cash`). `wcpos/v1` is the frozen authority, so both are reproduced |
| 243 |
* verbatim and the proxy lane now adopts them. Collapsing the two onto |
| 244 |
* `payment_method_title` is a deliberate behaviour change, deferred. |
| 245 |
*/ |
| 246 |
'payment_method' => array( |
| 247 |
'hpos' => array( 'column' => 'payment_method' ), |
| 248 |
'posts' => array( |
| 249 |
'meta_key' => '_payment_method_title', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg. |
| 250 |
'orderby' => 'meta_value', |
| 251 |
), |
| 252 |
), |
| 253 |
'total' => array( |
| 254 |
'hpos' => array( 'column' => 'total_amount' ), |
| 255 |
'posts' => array( |
| 256 |
'meta_key' => '_order_total', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg. |
| 257 |
'orderby' => 'meta_value_num', |
| 258 |
), |
| 259 |
), |
| 260 |
), |
| 261 |
'filters' => array( |
| 262 |
'pos_cashier' => array( |
| 263 |
'meta' => array( 'key' => '_pos_user' ), |
| 264 |
), |
| 265 |
'pos_store' => array( |
| 266 |
'meta' => array( 'key' => '_pos_store' ), |
| 267 |
), |
| 268 |
|
| 269 |
/* |
| 270 |
* `created_via` is a column of the HPOS operational-data table and a |
| 271 |
* postmeta value under legacy storage. The row exists for both, but |
| 272 |
* `wcpos/v1`'s param map omits the canonical name, so v1 continues not |
| 273 |
* to support it — a recorded product decision, not a silent gift. |
| 274 |
*/ |
| 275 |
'created_via' => array( |
| 276 |
'meta' => array( |
| 277 |
'key' => '_created_via', |
| 278 |
'storage' => self::STORAGE_POSTS, |
| 279 |
), |
| 280 |
'hpos_data' => array( |
| 281 |
'table' => 'operational_data', |
| 282 |
'column' => 'created_via', |
| 283 |
), |
| 284 |
'sanitize' => 'key', |
| 285 |
), |
| 286 |
'include' => array( |
| 287 |
'id_set' => array( 'operator' => 'IN' ), |
| 288 |
), |
| 289 |
'exclude' => array( |
| 290 |
'id_set' => array( 'operator' => 'NOT IN' ), |
| 291 |
), |
| 292 |
), |
| 293 |
), |
| 294 |
|
| 295 |
/* |
| 296 |
* The POS grid's SKU / barcode / stock columns, for the product grid and the |
| 297 |
* variation grid alike — the SAME four rows, from one builder, because the two |
| 298 |
* surfaces drifted apart once already and a cashier sorting a column expects |
| 299 |
* the same thing of both. |
| 300 |
* |
| 301 |
* A `meta_sort` row sorts on a postmeta value WITHOUT letting the sort decide |
| 302 |
* which records exist. The obvious encoding — WP_Query's `meta_key` + |
| 303 |
* `orderby => meta_value` — INNER JOINs `postmeta`, so a record with no row for |
| 304 |
* that key VANISHES from the result. On a default store the barcode field is |
| 305 |
* `_global_unique_id`, which most catalogues never populate, so sorting by |
| 306 |
* barcode returned an EMPTY page; `orderby=sku` silently dropped everything |
| 307 |
* without a SKU. A sort must never hide a record from a cashier, so these rows |
| 308 |
* are applied as a LEFT JOIN with the meta-less rows ordered LAST in both |
| 309 |
* directions (`Collection_Rules_Plan::apply_meta_sort_clauses()`). |
| 310 |
* |
| 311 |
* Neither collection is ever HPOS — both are posts on every store — so there is |
| 312 |
* no `hpos` half to these rows. |
| 313 |
*/ |
| 314 |
'products' => array( |
| 315 |
'sorts' => self::catalog_meta_sorts(), |
| 316 |
), |
| 317 |
'variations' => array( |
| 318 |
'sorts' => self::catalog_meta_sorts(), |
| 319 |
), |
| 320 |
|
| 321 |
/* |
| 322 |
* SORT NAMES ONLY — deliberately no clause bodies. |
| 323 |
* |
| 324 |
* Customers are a `WP_User_Query` over `wp_users`/`wp_usermeta`, a storage |
| 325 |
* this table does not speak: it knows `hpos` and `posts`, and both are ORDER |
| 326 |
* storages. The clause bodies therefore stay in each lane's own |
| 327 |
* `woocommerce_rest_customer_query` callback, where they are byte-identical. |
| 328 |
* |
| 329 |
* What DID drift is the LIST. The v1 schema enum and the proxy's claim list |
| 330 |
* were hand-kept in two files, so a sort could be advertised on one lane and |
| 331 |
* silently forwarded to wc/v3 (which cannot express it) on the other. Both |
| 332 |
* lanes now read `orderby_enum( 'customers' )`, which makes that impossible. |
| 333 |
* |
| 334 |
* Giving these rows real bodies needs a third storage dialect and two clause |
| 335 |
* kinds this table has never expressed; that is a later increment. |
| 336 |
*/ |
| 337 |
'customers' => array( |
| 338 |
'sorts' => array( |
| 339 |
'first_name' => array(), |
| 340 |
'last_name' => array(), |
| 341 |
'email' => array(), |
| 342 |
'role' => array(), |
| 343 |
'username' => array(), |
| 344 |
), |
| 345 |
), |
| 346 |
); |
| 347 |
|
| 348 |
return $rules[ $collection ] ?? array(); |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* The four POS column sorts, shared by `products` and `variations`. |
| 353 |
* |
| 354 |
* One builder rather than two copied blocks: these two collections carry the same |
| 355 |
* cashier-facing columns, and the previous copy-per-controller encoding is exactly how |
| 356 |
* the variation lane kept a defect the product lane had already fixed. |
| 357 |
* |
| 358 |
* @return array<string, array> |
| 359 |
*/ |
| 360 |
private static function catalog_meta_sorts(): array { |
| 361 |
return array( |
| 362 |
'sku' => array( |
| 363 |
'posts' => array( |
| 364 |
'meta_sort' => array( 'key' => '_sku' ), |
| 365 |
), |
| 366 |
), |
| 367 |
|
| 368 |
/* |
| 369 |
* The barcode meta key is a store setting, so the row reads the same accessor |
| 370 |
* the controllers do rather than hard-coding a key that would drift. |
| 371 |
*/ |
| 372 |
'barcode' => array( |
| 373 |
'posts' => array( |
| 374 |
'meta_sort' => array( 'key' => Barcode_Field::orderby_key() ), |
| 375 |
), |
| 376 |
), |
| 377 |
|
| 378 |
/* |
| 379 |
* `_stock` is written as NULL for everything that does not manage stock, so this |
| 380 |
* row needs the same meta-less-last ordering as the rest — it is not a special |
| 381 |
* case, it was merely the first one noticed. |
| 382 |
*/ |
| 383 |
'stock_quantity' => array( |
| 384 |
'posts' => array( |
| 385 |
'meta_sort' => array( |
| 386 |
'key' => '_stock', |
| 387 |
'numeric' => true, |
| 388 |
), |
| 389 |
), |
| 390 |
), |
| 391 |
'stock_status' => array( |
| 392 |
'posts' => array( |
| 393 |
'meta_sort' => array( 'key' => '_stock_status' ), |
| 394 |
), |
| 395 |
), |
| 396 |
); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Resolve the storage dialect for a collection when the caller did not name one. |
| 401 |
* |
| 402 |
* @param string $collection Collection slug. |
| 403 |
* |
| 404 |
* @return string |
| 405 |
*/ |
| 406 |
private static function detect_storage( string $collection ): string { |
| 407 |
if ( 'orders' !== $collection ) { |
| 408 |
return self::STORAGE_POSTS; |
| 409 |
} |
| 410 |
|
| 411 |
return class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() |
| 412 |
? self::STORAGE_HPOS |
| 413 |
: self::STORAGE_POSTS; |
| 414 |
} |
| 415 |
} |
| 416 |
|