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 / Sync / Collection_Rules.php

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

416 lines 16.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, 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