PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Collection_Rules.php

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

488 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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. Legacy callbacks can still delegate
39 * to it; collection reads use `around()` for search and visibility.
40 * - `Collection_Rules_Plan::around()` — the scoped read lanes, 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 /** Shared search bound; over-limit policies remain collection-specific. */
83 public const SEARCH_TERM_CAP = 10;
84
85 /**
86 * Split literal terms. Orders now also drop Unicode control characters between terms.
87 *
88 * Existing defaults: product phrase null uses WP_Query terms; orders use the supplied
89 * string; variation args/discovery default to '', and validation casts null to ''.
90 * Product phrases collapse over-cap terms; orders slice; v2 variations reject.
91 * Every product/variation lane uses this splitter; v1 collapses over-cap, v2 flat variations reject.
92 * Malformed UTF-8 yields an empty array, including offset-capture callers.
93 *
94 * @param string $search Search text.
95 * @param int $flags Split flags.
96 * @return array
97 */
98 public static function search_terms( $search, $flags = PREG_SPLIT_NO_EMPTY ) {
99 $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', $search, -1, $flags );
100 return false === $terms ? array() : $terms;
101 }
102
103 /**
104 * Legacy storage — `wp_posts` plus `wp_postmeta`.
105 *
106 * @var string
107 */
108 public const STORAGE_POSTS = 'posts';
109
110 /**
111 * Memoized plans, keyed by collection, request identity/content, storage and param map.
112 *
113 * Each entry is `array( WP_REST_Request, Collection_Rules_Plan )`; the request is
114 * kept so a recycled `spl_object_id` can never serve another request's plan.
115 *
116 * @var array<string, array{0: WP_REST_Request, 1: Collection_Rules_Plan}>
117 */
118 private static $plans = array();
119
120 /**
121 * Ceiling on the memo table, so a long-running process cannot grow it without bound.
122 *
123 * @var int
124 */
125 private const PLAN_CACHE_LIMIT = 32;
126
127 /**
128 * Build (or return the memoized) plan for one collection read.
129 *
130 * Pure: it reads the request and the declaration rows and nothing else. It never
131 * returns null and never throws — an unknown collection is an empty plan.
132 *
133 * @param string $collection Collection slug, e.g. `orders`.
134 * @param WP_REST_Request $request The request whose params the plan claims from.
135 * @param array $param_map Canonical name => request key (see class docblock).
136 * @param string|null $storage Storage dialect, or null to detect it.
137 *
138 * @return Collection_Rules_Plan
139 */
140 public static function for_request( string $collection, WP_REST_Request $request, array $param_map = array(), ?string $storage = null ) {
141 $storage = $storage ?? self::detect_storage( $collection );
142 $key = $collection . '|' . spl_object_id( $request ) . '|' . $storage . '|' . md5( (string) wp_json_encode( array( $param_map, $request->get_route(), $request->get_params() ) ) );
143
144 if ( isset( self::$plans[ $key ] ) && self::$plans[ $key ][0] === $request ) {
145 return self::$plans[ $key ][1];
146 }
147
148 if ( \count( self::$plans ) >= self::PLAN_CACHE_LIMIT ) {
149 self::$plans = array();
150 }
151
152 $plan = new Collection_Rules_Plan( $collection, self::rules( $collection ), $storage, $request, $param_map );
153 self::$plans[ $key ] = array( $request, $plan );
154
155 return $plan;
156 }
157
158 /**
159 * The `orderby` values this collection adds to the wc/v3 enum.
160 *
161 * A PROJECTION of the sort rows: the v1 REST schema, the proxy's claim list and the
162 * clause bodies all read this, so a sort cannot be advertised without being wired
163 * (or wired without being advertised).
164 *
165 * @param string $collection Collection slug.
166 *
167 * @return string[]
168 */
169 public static function orderby_enum( string $collection ): array {
170 $rules = self::rules( $collection );
171
172 return array_keys( $rules['sorts'] ?? array() );
173 }
174
175 /**
176 * The extra REST collection params this collection's filter rows require.
177 *
178 * A PROJECTION of the filter rows, in declaration order, shaped for
179 * `WP_REST_Controller::get_collection_params()`.
180 *
181 * @param string $collection Collection slug.
182 *
183 * @return array<string, array>
184 */
185 public static function collection_params( string $collection ): array {
186 $params = array();
187
188 if ( 'orders' === $collection ) {
189 $params['pos_cashier'] = array(
190 'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS cashier.', 'woocommerce-pos' ),
191 'type' => 'integer',
192 'required' => false,
193 );
194 // @NOTE - this is different to 'store_id' which is the store the request was made from.
195 $params['pos_store'] = array(
196 'description' => /* translators: REST API schema field label or error message. */ __( 'Filter orders by POS store.', 'woocommerce-pos' ),
197 'type' => 'integer',
198 'required' => false,
199 );
200 }
201
202 return $params;
203 }
204
205 /**
206 * The declaration rows for one collection.
207 *
208 * Closed table, private to the module in spirit — public only so the plan can be
209 * constructed from it and so pure tests can assert the rows without a bootstrap.
210 *
211 * A sort row MAY be bodiless (`array()`) when the collection's clauses live outside
212 * this module — see the `customers` rows. Such a row still projects into
213 * `orderby_enum()`, which is the whole point: one list, both lanes.
214 *
215 * Sort row shape (per storage):
216 * - `hpos` => `array( 'column' => <wc_orders column> )`
217 * - `posts` => `array( 'posts_orderby' => <wp_posts column> )` for a column the
218 * WP_Query `orderby` vocabulary cannot express (rewritten through
219 * `posts_orderby`), OR
220 * `array( 'meta_key' => ..., 'orderby' => meta_value|meta_value_num )`.
221 * - `posts` => `array( 'meta_sort' => array( 'key' => ..., 'numeric' => bool ) )`
222 * a postmeta sort that must NOT filter: applied as a LEFT JOIN through
223 * `posts_clauses`, with rows that have no value for the key ordered
224 * LAST in both directions. Use this for any user-facing column sort —
225 * `meta_key`/`orderby` INNER JOINs and silently drops rows.
226 *
227 * Filter row shape:
228 * - `meta` => `array( 'key' => <meta key>, 'storage' => <optional storage lock> )`
229 * a `meta_query` row on the WC query args (works on both storages).
230 * - `hpos_data` => `array( 'table' => ..., 'column' => ... )` an id subquery against
231 * one of HPOS's side tables, for data that is a COLUMN under HPOS
232 * and postmeta under legacy.
233 * - `id_set` => `array( 'operator' => 'IN'|'NOT IN' )` a raw id set the rule owns
234 * outright (see the `when => search` note in the class docblock).
235 * - `sanitize` => optional `'key'`, applied to each claimed value.
236 *
237 * @internal
238 *
239 * @param string $collection Collection slug.
240 *
241 * @return array{sorts?: array<string, array>, filters?: array<string, array>, search?: array<string, mixed>, visibility?: array<string, mixed>}
242 */
243 public static function rules( string $collection ): array {
244 $rules = array(
245 'orders' => array(
246 'search' => array(
247 'param' => 'search',
248 'term_cap' => self::SEARCH_TERM_CAP,
249 'rank_exact' => false,
250 'carriers' => array( 'id', 'billing_email', 'first_name', 'last_name', 'company', 'email', 'phone' ),
251 'hpos' => array(
252 'orders' => array( 'id', 'billing_email' ),
253 'addresses' => array( 'first_name', 'last_name', 'company', 'email', 'phone' ),
254 ),
255 'posts' => array(
256 'id' => 'ID',
257 'meta' => array( '_billing_first_name', '_billing_last_name', '_billing_company', '_billing_email', '_billing_phone' ),
258 ),
259 ),
260 'sorts' => array(
261 'status' => array(
262 'hpos' => array( 'column' => 'status' ),
263 'posts' => array( 'posts_orderby' => 'post_status' ),
264 ),
265 'customer_id' => array(
266 'hpos' => array( 'column' => 'customer_id' ),
267 'posts' => array(
268 'meta_key' => '_customer_user', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg.
269 'orderby' => 'meta_value_num',
270 ),
271 ),
272
273 /*
274 * PARITY PIN: the two storages sort DIFFERENT things and always have.
275 * HPOS sorts the gateway id (`wc_orders.payment_method`, e.g. `pos_cash`);
276 * legacy sorts the merchant-visible title meta (`_payment_method_title`,
277 * e.g. `Cash`). `wcpos/v1` is the frozen authority, so both are reproduced
278 * verbatim and the proxy lane now adopts them. Collapsing the two onto
279 * `payment_method_title` is a deliberate behaviour change, deferred.
280 */
281 'payment_method' => array(
282 'hpos' => array( 'column' => 'payment_method' ),
283 'posts' => array(
284 'meta_key' => '_payment_method_title', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg.
285 'orderby' => 'meta_value',
286 ),
287 ),
288 'total' => array(
289 'hpos' => array( 'column' => 'total_amount' ),
290 'posts' => array(
291 'meta_key' => '_order_total', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Declaration row, not a live query arg.
292 'orderby' => 'meta_value_num',
293 ),
294 ),
295 ),
296 'filters' => array(
297 'pos_cashier' => array(
298 'meta' => array( 'key' => '_pos_user' ),
299 ),
300 'pos_store' => array(
301 'meta' => array( 'key' => '_pos_store' ),
302 ),
303
304 /*
305 * `created_via` is a column of the HPOS operational-data table and a
306 * postmeta value under legacy storage. The row exists for both, but
307 * `wcpos/v1`'s param map omits the canonical name, so v1 continues not
308 * to support it — a recorded product decision, not a silent gift.
309 */
310 'created_via' => array(
311 'meta' => array(
312 'key' => '_created_via',
313 'storage' => self::STORAGE_POSTS,
314 ),
315 'hpos_data' => array(
316 'table' => 'operational_data',
317 'column' => 'created_via',
318 ),
319 'sanitize' => 'key',
320 ),
321 'include' => array(
322 'id_set' => array( 'operator' => 'IN' ),
323 ),
324 'exclude' => array(
325 'id_set' => array( 'operator' => 'NOT IN' ),
326 ),
327 ),
328 ),
329
330 /*
331 * The POS grid's SKU / barcode / stock columns, for the product grid and the
332 * variation grid alike — the SAME four rows, from one builder, because the two
333 * surfaces drifted apart once already and a cashier sorting a column expects
334 * the same thing of both.
335 *
336 * A `meta_sort` row sorts on a postmeta value WITHOUT letting the sort decide
337 * which records exist. The obvious encoding — WP_Query's `meta_key` +
338 * `orderby => meta_value` — INNER JOINs `postmeta`, so a record with no row for
339 * that key VANISHES from the result. On a default store the barcode field is
340 * `_global_unique_id`, which most catalogues never populate, so sorting by
341 * barcode returned an EMPTY page; `orderby=sku` silently dropped everything
342 * without a SKU. A sort must never hide a record from a cashier, so these rows
343 * are applied as a LEFT JOIN with the meta-less rows ordered LAST in both
344 * directions (`Collection_Rules_Plan::apply_meta_sort_clauses()`).
345 *
346 * Neither collection is ever HPOS — both are posts on every store — so there is
347 * no `hpos` half to these rows.
348 */
349 'products' => array(
350 'search' => array(
351 'param' => 'search',
352 // Both product lanes use the literal splitter and collapse over-cap terms to the phrase.
353 'term_cap' => self::SEARCH_TERM_CAP,
354 'rank_exact' => true,
355 'carriers' => array_merge( array( 'post_title' ), Barcode_Field::search_keys() ),
356 'posts' => array( 'meta' => Barcode_Field::search_keys() ),
357 'hpos' => null,
358 ),
359 'visibility' => array(
360 'type' => array(
361 'direct' => Pos_Visibility::PRODUCTS,
362 'proxy' => Pos_Visibility::CATALOG,
363 ),
364 'where_backstop' => true,
365 ),
366 'sorts' => self::catalog_meta_sorts(),
367 ),
368 'variations' => array(
369 'search' => array(
370 'param' => 'search',
371 'term_cap' => self::SEARCH_TERM_CAP,
372 'rank_exact' => false,
373 'carriers' => Barcode_Field::search_keys(),
374 'posts' => array( 'meta' => Barcode_Field::search_keys() ),
375 'hpos' => null,
376 'query' => 'meta_query',
377 'exact_sku_param' => 'sku',
378 // Both lanes split literal terms; v1 keeps over-cap collapse/EXISTS, v2 rejects/meta_query.
379 'lanes' => array(
380 'direct' => array(
381 'query' => 'wp_terms',
382 'exact_sku_param' => null,
383 ),
384 ),
385 ),
386 'visibility' => array(
387 'type' => Pos_Visibility::VARIATIONS,
388 'where_backstop' => true,
389 ),
390 'sorts' => self::catalog_meta_sorts(),
391 ),
392
393 /*
394 * SORT NAMES ONLY — deliberately no clause bodies.
395 *
396 * Customers are a `WP_User_Query` over `wp_users`/`wp_usermeta`, a storage
397 * this table does not speak: it knows `hpos` and `posts`, and both are ORDER
398 * storages. The clause bodies therefore stay in each lane's own
399 * `woocommerce_rest_customer_query` callback, where they are byte-identical.
400 *
401 * What DID drift is the LIST. The v1 schema enum and the proxy's claim list
402 * were hand-kept in two files, so a sort could be advertised on one lane and
403 * silently forwarded to wc/v3 (which cannot express it) on the other. Both
404 * lanes now read `orderby_enum( 'customers' )`, which makes that impossible.
405 *
406 * Giving these rows real bodies needs a third storage dialect and two clause
407 * kinds this table has never expressed; that is a later increment.
408 */
409 'customers' => array(
410 'sorts' => array(
411 'first_name' => array(),
412 'last_name' => array(),
413 'email' => array(),
414 'role' => array(),
415 'username' => array(),
416 ),
417 ),
418 );
419
420 return $rules[ $collection ] ?? array();
421 }
422
423 /**
424 * The four POS column sorts, shared by `products` and `variations`.
425 *
426 * One builder rather than two copied blocks: these two collections carry the same
427 * cashier-facing columns, and the previous copy-per-controller encoding is exactly how
428 * the variation lane kept a defect the product lane had already fixed.
429 *
430 * @return array<string, array>
431 */
432 private static function catalog_meta_sorts(): array {
433 return array(
434 'sku' => array(
435 'posts' => array(
436 'meta_sort' => array( 'key' => '_sku' ),
437 ),
438 ),
439
440 /*
441 * The barcode meta key is a store setting, so the row reads the same accessor
442 * the controllers do rather than hard-coding a key that would drift.
443 */
444 'barcode' => array(
445 'posts' => array(
446 'meta_sort' => array( 'key' => Barcode_Field::orderby_key() ),
447 ),
448 ),
449
450 /*
451 * `_stock` is written as NULL for everything that does not manage stock, so this
452 * row needs the same meta-less-last ordering as the rest — it is not a special
453 * case, it was merely the first one noticed.
454 */
455 'stock_quantity' => array(
456 'posts' => array(
457 'meta_sort' => array(
458 'key' => '_stock',
459 'numeric' => true,
460 ),
461 ),
462 ),
463 'stock_status' => array(
464 'posts' => array(
465 'meta_sort' => array( 'key' => '_stock_status' ),
466 ),
467 ),
468 );
469 }
470
471 /**
472 * Resolve the storage dialect for a collection when the caller did not name one.
473 *
474 * @param string $collection Collection slug.
475 *
476 * @return string
477 */
478 public static function detect_storage( string $collection ): string {
479 if ( 'orders' !== $collection ) {
480 return self::STORAGE_POSTS;
481 }
482
483 return class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled()
484 ? self::STORAGE_HPOS
485 : self::STORAGE_POSTS;
486 }
487 }
488