PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.2
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.2
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 / Collection_Rules.php

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

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