PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.0.0-beta.1
WooCommerce v11.0.0-beta.1
11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / DataStores / Orders / OrdersTableQuery.php
woocommerce / src / Internal / DataStores / Orders Last commit date
CustomOrdersTableController.php 5 days ago DataSynchronizer.php 7 months ago HposOrderCapabilityHelper.php 5 days ago LegacyDataCleanup.php 1 year ago LegacyDataHandler.php 5 months ago OrdersTableDataStore.php 5 days ago OrdersTableDataStoreMeta.php 5 days ago OrdersTableFieldQuery.php 2 years ago OrdersTableMetaQuery.php 1 year ago OrdersTableQuery.php 5 days ago OrdersTableRefundDataStore.php 2 months ago OrdersTableSearchQuery.php 11 months ago OrdersTableStatusUnionQuery.php 5 days ago
OrdersTableQuery.php
1604 lines
1 <?php
2 // phpcs:disable Generic.Commenting.Todo.TaskFound
3 /**
4 * OrdersTableQuery class file.
5 */
6
7 namespace Automattic\WooCommerce\Internal\DataStores\Orders;
8
9 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
10
11 defined( 'ABSPATH' ) || exit;
12
13 /**
14 * This class provides a `WP_Query`-like interface to custom order tables.
15 *
16 * @property-read int $found_orders Number of found orders.
17 * @property-read int $found_posts Alias of the `$found_orders` property.
18 * @property-read int $max_num_pages Max number of pages matching the current query.
19 * @property-read array $orders Order objects, or order IDs.
20 * @property-read array $posts Alias of the $orders property.
21 */
22 class OrdersTableQuery {
23
24 /**
25 * Values to ignore when parsing query arguments.
26 */
27 public const SKIPPED_VALUES = array( '', array(), null );
28
29 /**
30 * Regex used to catch "shorthand" comparisons in date-related query args.
31 */
32 public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';
33
34 /**
35 * Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
36 *
37 * @var array
38 */
39 private $tables = array();
40
41 /**
42 * Column mappings for all COT tables.
43 *
44 * @var array
45 */
46 private $mappings = array();
47
48 /**
49 * Query vars after processing and sanitization.
50 *
51 * @var array
52 */
53 private $args = array();
54
55 /**
56 * Original query vars used to build this query.
57 *
58 * @var array
59 */
60 private $query_args = array();
61
62 /**
63 * Columns to be selected in the SELECT clause.
64 *
65 * @var array
66 */
67 private $fields = array();
68
69 /**
70 * Array of table aliases and conditions used to compute the JOIN clause of the query.
71 *
72 * @var array
73 */
74 private $join = array();
75
76 /**
77 * Array of fields and conditions used to compute the WHERE clause of the query.
78 *
79 * @var array
80 */
81 private $where = array();
82
83 /**
84 * Field to be used in the GROUP BY clause of the query.
85 *
86 * @var array
87 */
88 private $groupby = array();
89
90 /**
91 * Array of fields used to compute the ORDER BY clause of the query.
92 *
93 * @var array
94 */
95 private $orderby = array();
96
97 /**
98 * Limits used to compute the LIMIT clause of the query.
99 *
100 * @var array
101 */
102 private $limits = array();
103
104 /**
105 * Results (order IDs) for the current query.
106 *
107 * @var array
108 */
109 private $orders = array();
110
111 /**
112 * Final SQL query to run after processing of args.
113 *
114 * @var string
115 */
116 private $sql = '';
117
118 /**
119 * Final SQL query to count results after processing of args.
120 *
121 * @var string
122 */
123 private $count_sql = '';
124
125 /**
126 * The number of pages (when pagination is enabled).
127 *
128 * @var int
129 */
130 private $max_num_pages = 0;
131
132 /**
133 * The number of orders found.
134 *
135 * @var int
136 */
137 private $found_orders = 0;
138
139 /**
140 * Field query parser.
141 *
142 * @var OrdersTableFieldQuery
143 */
144 private $field_query = null;
145
146 /**
147 * Meta query parser.
148 *
149 * @var OrdersTableMetaQuery
150 */
151 private $meta_query = null;
152
153 /**
154 * Search query parser.
155 *
156 * @var OrdersTableSearchQuery?
157 */
158 private $search_query = null;
159
160 /**
161 * Date query parser.
162 *
163 * @var WP_Date_Query
164 */
165 private $date_query = null;
166
167 /**
168 * Instance of the OrdersTableDataStore class.
169 *
170 * @var OrdersTableDataStore
171 */
172 private $order_datastore = null;
173
174 /**
175 * Whether to run filters to modify the query or not.
176 *
177 * @var boolean
178 */
179 private $suppress_filters = false;
180
181 /**
182 * Sets up and runs the query after processing arguments.
183 *
184 * @param array $args Array of query vars.
185 */
186 public function __construct( $args = array() ) {
187 // Note that ideally we would inject this dependency via constructor, but that's not possible since this class needs to be backward compatible with WC_Order_Query class.
188 $this->order_datastore = wc_get_container()->get( OrdersTableDataStore::class );
189
190 $this->tables = $this->order_datastore::get_all_table_names_with_id();
191 $this->mappings = $this->order_datastore->get_all_order_column_mappings();
192
193 $this->suppress_filters = array_key_exists( 'suppress_filters', $args ) ? (bool) $args['suppress_filters'] : false;
194 unset( $args['suppress_filters'] );
195
196 $this->args = $args;
197 $this->query_args = $args; // Keep a copy of the original vars used to initialize the query.
198
199 // TODO: 'name' arg (post_name equivalent) is not yet implemented for HPOS.
200 unset( $this->args['name'] );
201
202 $this->build_query();
203 if ( ! $this->maybe_override_query() ) {
204 $this->run_query();
205 }
206 }
207
208 /**
209 * Lets the `woocommerce_hpos_pre_query` filter override the query.
210 *
211 * @return boolean Whether the query was overridden or not.
212 */
213 private function maybe_override_query(): bool {
214 /**
215 * Filters the orders array before the query takes place.
216 *
217 * Return a non-null value to bypass the HPOS default order queries.
218 *
219 * If the query includes limits via the `limit`, `page`, or `offset` arguments, we
220 * encourage the `found_orders` and `max_num_pages` properties to also be set.
221 *
222 * @since 8.2.0
223 *
224 * @param array|null $order_data {
225 * An array of order data.
226 * @type int[] $orders Return an array of order IDs data to short-circuit the HPOS query,
227 * or null to allow HPOS to run its normal query.
228 * @type int $found_orders The number of orders found.
229 * @type int $max_num_pages The number of pages.
230 * }
231 * @param OrdersTableQuery $query The OrdersTableQuery instance.
232 * @param string $sql Fully built SQL query.
233 */
234 $pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
235 if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
236 return false;
237 }
238
239 // If the filter set the orders, make sure the others values are set as well and skip running the query.
240 list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;
241
242 if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
243 $this->found_orders = count( $this->orders );
244 }
245
246 if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
247 if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
248 $this->args['limit'] = 10;
249 }
250 $this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
251 }
252
253 return true;
254 }
255
256 /**
257 * Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
258 *
259 * @return void
260 */
261 private function maybe_remap_args(): void {
262 $mapping = array(
263 // WP_Query legacy.
264 'post_date' => 'date_created',
265 'post_date_gmt' => 'date_created_gmt',
266 'post_modified' => 'date_updated',
267 'post_modified_gmt' => 'date_updated_gmt',
268 'post_status' => 'status',
269 '_date_completed' => 'date_completed',
270 '_date_paid' => 'date_paid',
271 'paged' => 'page',
272 'post_parent' => 'parent_order_id',
273 'post_parent__in' => 'parent_order_id',
274 'post_parent__not_in' => 'parent_exclude',
275 'post__not_in' => 'exclude',
276 'posts_per_page' => 'limit',
277 'p' => 'id',
278 'post__in' => 'id',
279 'post_type' => 'type',
280 'fields' => 'return',
281
282 'customer_user' => 'customer_id',
283 'order_currency' => 'currency',
284 'order_version' => 'woocommerce_version',
285 'cart_discount' => 'discount_total_amount',
286 'cart_discount_tax' => 'discount_tax_amount',
287 'order_shipping' => 'shipping_total_amount',
288 'order_shipping_tax' => 'shipping_tax_amount',
289 'order_tax' => 'tax_amount',
290
291 // Translate from WC_Order_Query to table structure.
292 'version' => 'woocommerce_version',
293 'date_modified' => 'date_updated',
294 'date_modified_gmt' => 'date_updated_gmt',
295 'discount_total' => 'discount_total_amount',
296 'discount_tax' => 'discount_tax_amount',
297 'shipping_total' => 'shipping_total_amount',
298 'shipping_tax' => 'shipping_tax_amount',
299 'cart_tax' => 'tax_amount',
300 'total' => 'total_amount',
301 'customer_ip_address' => 'ip_address',
302 'customer_user_agent' => 'user_agent',
303 'parent' => 'parent_order_id',
304 );
305
306 foreach ( $mapping as $query_key => $table_field ) {
307 if ( isset( $this->args[ $query_key ] ) && '' !== $this->args[ $query_key ] ) {
308 $this->args[ $table_field ] = $this->args[ $query_key ];
309 unset( $this->args[ $query_key ] );
310 }
311 }
312
313 // meta_query.
314 $this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
315
316 $shortcut_meta_query = array();
317 foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
318 if ( $this->arg_isset( "meta_{$key}" ) ) {
319 $shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
320 }
321 }
322
323 if ( ! empty( $shortcut_meta_query ) ) {
324 if ( ! empty( $this->args['meta_query'] ) ) {
325 $this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
326 'relation' => 'AND',
327 $shortcut_meta_query,
328 $this->args['meta_query'],
329 );
330 } else {
331 $this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
332 }
333 }
334 }
335
336 /**
337 * Generates a `WP_Date_Query` compatible query from a given date.
338 * YYYY-MM-DD queries have 'day' precision for backwards compatibility.
339 *
340 * @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
341 * @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
342 */
343 private function date_to_date_query_arg( $date ): array {
344 $result = array(
345 'year' => '',
346 'month' => '',
347 'day' => '',
348 );
349
350 $precision = null;
351 if ( is_numeric( $date ) ) {
352 $date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
353 $precision = 'second';
354 } elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
355 // For backwards compat (see https://developer.woocommerce.com/docs/extensions/core-concepts/wc-get-orders/#date)
356 // only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
357 $date = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
358 $precision = 'day';
359 }
360
361 $result['year'] = $date->date( 'Y' );
362 $result['month'] = $date->date( 'm' );
363 $result['day'] = $date->date( 'd' );
364
365 if ( 'second' === $precision ) {
366 $result['hour'] = $date->date( 'H' );
367 $result['minute'] = $date->date( 'i' );
368 $result['second'] = $date->date( 's' );
369 }
370
371 return $result;
372 }
373
374 /**
375 * Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
376 *
377 * @param array $dates_raw Array of dates (in local time) to use in combination with the operator.
378 * @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
379 * @return array Partial date query arg with relevant dates now UTC-based.
380 *
381 * @throws \Exception If an invalid date shorthand operator is specified.
382 *
383 * @since 8.2.0
384 */
385 private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
386 $result = array();
387
388 // Convert YYYY-MM-DD to UTC timestamp. Per https://developer.woocommerce.com/docs/extensions/core-concepts/wc-get-orders/#date only date is relevant (time is ignored).
389 foreach ( $dates_raw as &$raw_date ) {
390 $raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
391 }
392
393 $date1 = end( $dates_raw );
394
395 switch ( $operator ) {
396 case '>':
397 $result = array(
398 'after' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
399 'inclusive' => true,
400 );
401 break;
402 case '>=':
403 $result = array(
404 'after' => $this->date_to_date_query_arg( $date1 ),
405 'inclusive' => true,
406 );
407 break;
408 case '=':
409 $result = array(
410 'relation' => 'AND',
411 array(
412 'after' => $this->date_to_date_query_arg( $date1 ),
413 'inclusive' => true,
414 ),
415 array(
416 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
417 'inclusive' => false,
418 ),
419 );
420 break;
421 case '<=':
422 $result = array(
423 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
424 'inclusive' => false,
425 );
426 break;
427 case '<':
428 $result = array(
429 'before' => $this->date_to_date_query_arg( $date1 ),
430 'inclusive' => false,
431 );
432 break;
433 case '...':
434 $result = array(
435 'relation' => 'AND',
436 $this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
437 $this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
438 );
439
440 break;
441 }
442
443 if ( ! $result ) {
444 throw new \Exception( 'Please specify a valid date shorthand operator.' );
445 }
446
447 return $result;
448 }
449
450 /**
451 * Processes date-related query args and merges the result into 'date_query'.
452 *
453 * @return void
454 * @throws \Exception When date args are invalid.
455 */
456 private function process_date_args(): void {
457 if ( $this->arg_isset( 'date_query' ) ) {
458 // Process already passed date queries args.
459 $this->args['date_query'] = $this->map_gmt_and_post_keys_to_hpos_keys( $this->args['date_query'] );
460 }
461
462 $valid_operators = array( '>', '>=', '=', '<=', '<', '...' );
463 $date_queries = array();
464 $local_to_gmt_date_keys = array(
465 'date_created' => 'date_created_gmt',
466 'date_updated' => 'date_updated_gmt',
467 'date_paid' => 'date_paid_gmt',
468 'date_completed' => 'date_completed_gmt',
469 );
470
471 $gmt_date_keys = array_values( $local_to_gmt_date_keys );
472 $local_date_keys = array_keys( $local_to_gmt_date_keys );
473
474 $valid_date_keys = array_merge( $gmt_date_keys, $local_date_keys );
475 $date_keys = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );
476
477 foreach ( $date_keys as $date_key ) {
478 $is_local = in_array( $date_key, $local_date_keys, true );
479 $date_value = $this->args[ $date_key ];
480 $operator = '=';
481 $dates_raw = array();
482 $dates = array();
483
484 if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
485 $operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
486
487 if ( ! empty( $matches[1] ) ) {
488 $dates_raw[] = $matches[1];
489 }
490
491 $dates_raw[] = $matches[3];
492 } else {
493 $dates_raw[] = $date_value;
494 }
495
496 if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
497 throw new \Exception( 'Invalid date_query' );
498 }
499
500 if ( $is_local ) {
501 $date_key = $local_to_gmt_date_keys[ $date_key ];
502
503 if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
504 // Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://developer.woocommerce.com/docs/extensions/core-concepts/wc-get-orders/#date.
505 $date_queries[] = array_merge(
506 array(
507 'column' => $date_key,
508 ),
509 $this->local_time_to_gmt_date_query( $dates_raw, $operator )
510 );
511
512 continue;
513 }
514 }
515
516 $operator_to_keys = array();
517
518 if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
519 $operator_to_keys[] = 'after';
520 }
521
522 if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
523 $operator_to_keys[] = 'before';
524 }
525
526 $dates = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
527 $date_queries[] = array_merge(
528 array(
529 'column' => $date_key,
530 'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
531 ),
532 '=' === $operator
533 ? end( $dates )
534 : array_combine( $operator_to_keys, $dates )
535 );
536 }
537
538 // Add top-level date parameters to the date_query.
539 $tl_query = array();
540 foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day' ) as $tl_key ) {
541 if ( $this->arg_isset( $tl_key ) ) {
542 $tl_query[ $tl_key ] = $this->args[ $tl_key ];
543 unset( $this->args[ $tl_key ] );
544 }
545 }
546
547 if ( $tl_query ) {
548 $tl_query['column'] = 'date_created_gmt';
549 $date_queries[] = $tl_query;
550 }
551
552 if ( $date_queries ) {
553 if ( ! $this->arg_isset( 'date_query' ) ) {
554 $this->args['date_query'] = array();
555 }
556
557 $this->args['date_query'] = array_merge(
558 array( 'relation' => 'AND' ),
559 $date_queries,
560 $this->args['date_query']
561 );
562 }
563
564 $this->process_date_query_columns();
565 }
566
567 /**
568 * Helper function to map posts and gmt based keys to HPOS keys.
569 *
570 * @param array $query Date query argument.
571 *
572 * @return array|mixed Date query argument with modified keys.
573 */
574 private function map_gmt_and_post_keys_to_hpos_keys( $query ) {
575 if ( ! is_array( $query ) ) {
576 return $query;
577 }
578
579 $post_to_hpos_mappings = array(
580 'post_date' => 'date_created',
581 'post_date_gmt' => 'date_created_gmt',
582 'post_modified' => 'date_updated',
583 'post_modified_gmt' => 'date_updated_gmt',
584 '_date_completed' => 'date_completed',
585 '_date_paid' => 'date_paid',
586 'date_modified' => 'date_updated',
587 'date_modified_gmt' => 'date_updated_gmt',
588 );
589
590 $local_to_gmt_date_keys = array(
591 'date_created' => 'date_created_gmt',
592 'date_updated' => 'date_updated_gmt',
593 'date_paid' => 'date_paid_gmt',
594 'date_completed' => 'date_completed_gmt',
595 );
596
597 array_walk(
598 $query,
599 function ( &$sub_query ) {
600 $sub_query = $this->map_gmt_and_post_keys_to_hpos_keys( $sub_query );
601 }
602 );
603
604 if ( ! isset( $query['column'] ) ) {
605 return $query;
606 }
607
608 if ( isset( $post_to_hpos_mappings[ $query['column'] ] ) ) {
609 $query['column'] = $post_to_hpos_mappings[ $query['column'] ];
610 }
611
612 // Convert any local dates to GMT.
613 if ( isset( $local_to_gmt_date_keys[ $query['column'] ] ) ) {
614 $query['column'] = $local_to_gmt_date_keys[ $query['column'] ];
615 $op = isset( $query['after'] ) ? 'after' : 'before';
616 $date_value_local = $query[ $op ];
617 $date_value_gmt = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
618 $query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt );
619 }
620
621 return $query;
622 }
623
624 /**
625 * Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
626 *
627 * @return void
628 */
629 private function process_date_query_columns() {
630 global $wpdb;
631
632 $legacy_columns = array(
633 'post_date' => 'date_created_gmt',
634 'post_date_gmt' => 'date_created_gmt',
635 'post_modified' => 'date_modified_gmt',
636 'post_modified_gmt' => 'date_updated_gmt',
637 );
638 $table_mapping = array(
639 'date_created_gmt' => $this->tables['orders'],
640 'date_updated_gmt' => $this->tables['orders'],
641 'date_paid_gmt' => $this->tables['operational_data'],
642 'date_completed_gmt' => $this->tables['operational_data'],
643 );
644
645 if ( empty( $this->args['date_query'] ) ) {
646 return;
647 }
648
649 array_walk_recursive(
650 $this->args['date_query'],
651 function ( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
652 if ( 'column' !== $key ) {
653 return;
654 }
655
656 // Translate legacy columns from wp_posts if necessary.
657 $value =
658 ( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
659 ? $legacy_columns[ $value ]
660 : $value;
661
662 $table = $table_mapping[ $value ] ?? null;
663
664 if ( ! $table ) {
665 return;
666 }
667
668 $value = "{$table}.{$value}";
669
670 if ( $table !== $this->tables['orders'] ) {
671 $this->join( $table, '', '', 'inner', true );
672 }
673 }
674 );
675 }
676
677 /**
678 * Sanitizes the 'status' query var.
679 *
680 * @return void
681 */
682 private function sanitize_status(): void {
683 $valid_statuses = array_keys( wc_get_order_statuses() );
684
685 if ( empty( $this->args['status'] ) ) {
686 $this->args['status'] = array();
687 }
688
689 if ( ! is_array( $this->args['status'] ) ) {
690 $this->args['status'] = array( $this->args['status'] );
691 }
692
693 if ( empty( $this->args['status'] ) || in_array( 'any', $this->args['status'], true ) ) {
694 // Querying for 'any' status or empty status, filter to valid statuses from wc_get_order_statuses(),
695 // excluding statuses marked as exclude_from_search (e.g. checkout-draft) to match WP_Query behavior.
696 $exclude = get_post_stati( array( 'exclude_from_search' => true ) );
697 $this->args['status'] = array_diff( $valid_statuses, $exclude );
698 } elseif ( in_array( 'all', $this->args['status'], true ) ) {
699 // Querying for 'all' status does not filter by status at all.
700 $this->args['status'] = array();
701 }
702
703 foreach ( $this->args['status'] as &$status ) {
704 $status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
705 }
706
707 $this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
708 }
709
710 /**
711 * Parses and sanitizes the 'orderby' query var.
712 *
713 * @param string|array $orderby The unsanitized orderby param which can be a string or an array of orderby keys and direction (ASC, DESC).
714 * @return string|array The sanitized orderby param which can be a string or an array of orderby keys and direction (ASC, DESC).
715 */
716 private function sanitize_order_orderby( $orderby ) {
717 // No need to sanitize, will be processed in calling function.
718 if ( 'include' === $orderby || 'post__in' === $orderby || 'none' === $orderby ) {
719 return $orderby;
720 }
721
722 // Translate $orderby to a valid field.
723 $mapping = array(
724 'ID' => "{$this->tables['orders']}.id",
725 'id' => "{$this->tables['orders']}.id",
726 'type' => "{$this->tables['orders']}.type",
727 'date' => "{$this->tables['orders']}.date_created_gmt",
728 'date_created' => "{$this->tables['orders']}.date_created_gmt",
729 'modified' => "{$this->tables['orders']}.date_updated_gmt",
730 'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
731 'parent' => "{$this->tables['orders']}.parent_order_id",
732 'total' => "{$this->tables['orders']}.total_amount",
733 'order_total' => "{$this->tables['orders']}.total_amount",
734 );
735
736 $order = $this->sanitize_order( $this->args['order'] ?? '' );
737 $allowed_orderby = array_merge( array_keys( $mapping ), array_values( $mapping ), $this->meta_query ? $this->meta_query->get_orderby_keys() : array() );
738
739 // Convert string orderby to an array of orderby keys and direction (ASC, DESC).
740 if ( is_string( $orderby ) ) {
741 $orderby_fields = array_map( 'trim', explode( ' ', $orderby ) );
742 $orderby = array();
743 foreach ( $orderby_fields as $field ) {
744 $orderby[ $field ] = $order;
745 }
746 }
747
748 $sanitized_orderby = array();
749
750 foreach ( $orderby as $order_key => $order ) {
751 if ( ! in_array( $order_key, $allowed_orderby, true ) ) {
752 continue;
753 }
754
755 if ( isset( $mapping[ $order_key ] ) ) {
756 $order_key = $mapping[ $order_key ];
757 }
758
759 $sanitized_orderby[ $order_key ] = $this->sanitize_order( $order );
760 }
761
762 return $sanitized_orderby;
763 }
764
765 /**
766 * Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
767 *
768 * @param string $order The unsanitized order.
769 * @return string The sanitized order.
770 */
771 private function sanitize_order( string $order ): string {
772 $order = strtoupper( $order );
773
774 return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
775 }
776
777 /**
778 * Builds the final SQL query to be run.
779 *
780 * @return void
781 */
782 private function build_query(): void {
783 $this->maybe_remap_args();
784
785 // Field queries.
786 if ( ! empty( $this->args['field_query'] ) ) {
787 $this->field_query = new OrdersTableFieldQuery( $this );
788 $sql = $this->field_query->get_sql_clauses();
789 $this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
790 $this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
791 }
792
793 // Build query.
794 $this->process_date_args();
795 $this->process_orders_table_query_args();
796 $this->process_operational_data_table_query_args();
797 $this->process_addresses_table_query_args();
798
799 // Search queries.
800 if ( ! empty( $this->args['s'] ) ) {
801 $this->search_query = new OrdersTableSearchQuery( $this );
802 $sql = $this->search_query->get_sql_clauses();
803 $this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
804 $this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
805 }
806
807 // Meta queries.
808 if ( ! empty( $this->args['meta_query'] ) ) {
809 $this->meta_query = new OrdersTableMetaQuery( $this );
810
811 $sql = $this->meta_query->get_sql_clauses();
812
813 $this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
814 $this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;
815
816 }
817
818 // Date queries.
819 if ( ! empty( $this->args['date_query'] ) ) {
820 $this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
821 $this->where[] = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
822 }
823
824 $this->process_orderby();
825 $this->process_limit();
826
827 $orders_table = $this->tables['orders'];
828
829 // Group by is a faster substitute for DISTINCT, as long as we are only selecting IDs. MySQL don't like it when we join tables and use DISTINCT.
830 $this->groupby[] = "{$this->tables['orders']}.id";
831 $this->fields = "{$orders_table}.id";
832 $fields = $this->fields;
833
834 // JOIN.
835 $join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );
836
837 // WHERE.
838 $where = '1=1';
839 foreach ( $this->where as $_where ) {
840 if ( strlen( $_where ) > 0 ) {
841 $where .= " AND ({$_where})";
842 }
843 }
844
845 // ORDER BY.
846 $orderby = $this->orderby ? implode( ', ', $this->orderby ) : '';
847
848 // LIMITS.
849 $limits = '';
850
851 if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
852 $offset = (int) ( $this->limits[0] ?? 0 );
853 $row_count = (int) ( $this->limits[1] ?? 0 );
854
855 if ( -1 === $row_count ) {
856 // For "unlimited" (-1) queries, mirror WP_Query's nopaging behavior and
857 // omit the LIMIT clause. When an offset is specified, MySQL requires a
858 // row count, so emit PHP_INT_MAX — portable across MySQL (well below
859 // its unsigned bigint max) and SQLite (its signed 64-bit max).
860 if ( $offset > 0 ) {
861 $limits = 'LIMIT ' . $offset . ', ' . PHP_INT_MAX;
862 }
863 } else {
864 $limits = 'LIMIT ' . $offset . ', ' . $row_count;
865 }
866 }
867
868 // GROUP BY.
869 $groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';
870
871 $pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );
872
873 if ( ! $this->suppress_filters ) {
874 /**
875 * Filters all query clauses at once.
876 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
877 *
878 * @since 7.9.0
879 *
880 * @param string[] $clauses {
881 * Associative array of the clauses for the query.
882 *
883 * @type string $fields The SELECT clause of the query.
884 * @type string $join The JOIN clause of the query.
885 * @type string $where The WHERE clause of the query.
886 * @type string $groupby The GROUP BY clause of the query.
887 * @type string $orderby The ORDER BY clause of the query.
888 * @type string $limits The LIMIT clause of the query.
889 * }
890 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
891 * @param array $args Query args.
892 */
893 $clauses = (array) apply_filters_ref_array( 'woocommerce_orders_table_query_clauses', array( $pieces, &$this, $this->args ) );
894
895 $fields = $clauses['fields'] ?? '';
896 $join = $clauses['join'] ?? '';
897 $where = $clauses['where'] ?? '';
898 $groupby = $clauses['groupby'] ?? '';
899 $orderby = $clauses['orderby'] ?? '';
900 $limits = $clauses['limits'] ?? '';
901 }
902
903 $groupby = $groupby ? ( 'GROUP BY ' . $groupby ) : '';
904 $orderby = $orderby ? ( 'ORDER BY ' . $orderby ) : '';
905
906 // Performance note: simplify the query to allow the query optimizer to select a more efficient execution plan. As of
907 // version 10.9, this logic is implemented here as alternative changes above are getting flagged by regression analysis.
908 if ( '' === $join && "{$orders_table}.id" === $fields ) {
909 $groupby = '';
910 }
911
912 $this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
913
914 $filtered_sql = $this->sql;
915 if ( ! $this->suppress_filters ) {
916 /**
917 * Filters the completed SQL query.
918 *
919 * Note: queries left unmodified by this filter may later be rewritten for performance (see
920 * OrdersTableStatusUnionQuery), in which case the SQL received here is not the SQL that ends up
921 * being executed. Returning a modified query from this filter disables any such rewrite.
922 *
923 * @since 7.9.0
924 *
925 * @param string $sql The complete SQL query.
926 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
927 * @param array $args Query args.
928 */
929 $filtered_sql = apply_filters_ref_array( 'woocommerce_orders_table_query_sql', array( $this->sql, &$this, $this->args ) );
930 }
931
932 if ( $filtered_sql === $this->sql ) {
933 // On large HPOS stores this multi-status, date-ordered query can get a slow plan (scanning millions of
934 // rows); rewriting it as a UNION of single-status queries lets the type_status_date index serve each
935 // branch. Only attempted when no 'woocommerce_orders_table_query_sql' callback changed the query. See
936 // OrdersTableStatusUnionQuery.
937 $status_union_sql = ( new OrdersTableStatusUnionQuery( $this ) )->get_sql(
938 compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' ),
939 $this->suppress_filters
940 );
941 $filtered_sql = $status_union_sql ?? $this->sql;
942 }
943
944 $this->sql = $filtered_sql;
945
946 $this->build_count_query( $fields, $join, $where, $groupby );
947 }
948
949 /**
950 * Build SQL query for counting total number of results.
951 *
952 * @param string $fields Prepared fields for SELECT clause.
953 * @param string $join Prepared JOIN clause.
954 * @param string $where Prepared WHERE clause.
955 * @param string $groupby Prepared GROUP BY clause.
956 */
957 private function build_count_query( $fields, $join, $where, $groupby ) {
958 if ( ! isset( $this->sql ) || '' === $this->sql ) {
959 wc_doing_it_wrong( __FUNCTION__, 'Count query can only be build after main query is built.', '7.3.0' );
960 }
961 $orders_table = $this->tables['orders'];
962 $count_fields = "COUNT(DISTINCT $fields)";
963 if ( "{$orders_table}.id" === $fields && '' === $join ) {
964 // DISTINCT adds performance overhead, exclude the DISTINCT function when confident it is not needed.
965 $count_fields = 'COUNT(*)';
966 }
967 $this->count_sql = "SELECT $count_fields FROM $orders_table $join WHERE $where";
968
969 if ( ! $this->suppress_filters ) {
970 /**
971 * Filters the count SQL query.
972 *
973 * @since 8.6.0
974 *
975 * @param string $sql The count SQL query.
976 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
977 * @param array $args Query args.
978 * @param string $fields Prepared fields for SELECT clause.
979 * @param string $join Prepared JOIN clause.
980 * @param string $where Prepared WHERE clause.
981 * @param string $groupby Prepared GROUP BY clause.
982 */
983 $this->count_sql = apply_filters_ref_array( 'woocommerce_orders_table_query_count_sql', array( $this->count_sql, &$this, $this->args, $fields, $join, $where, $groupby ) );
984 }
985 }
986
987 /**
988 * Returns the table alias for a given table mapping.
989 *
990 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
991 * @return string Table alias.
992 *
993 * @since 7.0.0
994 */
995 public function get_core_mapping_alias( string $mapping_id ): string {
996 return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
997 ? $mapping_id
998 : $this->tables[ $mapping_id ];
999 }
1000
1001 /**
1002 * Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
1003 *
1004 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
1005 * @return string The JOIN clause.
1006 *
1007 * @since 7.0.0
1008 */
1009 public function get_core_mapping_join( string $mapping_id ): string {
1010 global $wpdb;
1011
1012 if ( 'orders' === $mapping_id ) {
1013 return '';
1014 }
1015
1016 $is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );
1017
1018 $alias = $this->get_core_mapping_alias( $mapping_id );
1019 $table = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
1020 $join = '';
1021 $join_on = '';
1022
1023 $join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );
1024
1025 if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
1026 $join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
1027 }
1028
1029 if ( $is_address_mapping ) {
1030 $join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1031 }
1032
1033 return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
1034 }
1035
1036 /**
1037 * JOINs the main orders table with another table.
1038 *
1039 * @param string $table Table name (including prefix).
1040 * @param string $alias Table alias to use. Defaults to $table.
1041 * @param string $on ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
1042 * @param string $join_type JOIN type: LEFT, RIGHT or INNER.
1043 * @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
1044 * @return void
1045 * @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
1046 */
1047 private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
1048 $alias = empty( $alias ) ? $table : $alias;
1049 $join_type = strtoupper( trim( $join_type ) );
1050
1051 if ( $this->tables['orders'] === $alias ) {
1052 // translators: %s is a table name.
1053 throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
1054 }
1055
1056 if ( empty( $on ) ) {
1057 if ( $this->tables['orders'] === $table ) {
1058 $on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
1059 } else {
1060 $on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
1061 }
1062 }
1063
1064 if ( isset( $this->join[ $alias ] ) ) {
1065 if ( ! $alias_once ) {
1066 // translators: %s is a table name.
1067 throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
1068 }
1069
1070 return;
1071 }
1072
1073 if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
1074 $join_type = 'INNER';
1075 }
1076
1077 $sql_join = '';
1078 $sql_join .= "{$join_type} JOIN `{$table}` ";
1079 $sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
1080 $sql_join .= "ON ( {$on} )";
1081
1082 $this->join[ $alias ] = $sql_join;
1083 }
1084
1085 /**
1086 * Generates a properly escaped and sanitized WHERE condition for a given field.
1087 *
1088 * @param string $table The table the field belongs to.
1089 * @param string $field The field or column name.
1090 * @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
1091 * @param mixed $value The value.
1092 * @param string $type The column type as specified in {@see OrdersTableDataStore} column mappings.
1093 * @return string The resulting WHERE condition.
1094 */
1095 public function where( string $table, string $field, string $operator, $value, string $type ): string {
1096 global $wpdb;
1097
1098 $db_util = wc_get_container()->get( DatabaseUtil::class );
1099 $operator = strtoupper( '' !== $operator ? $operator : '=' );
1100
1101 try {
1102 $format = $db_util->get_wpdb_format_for_type( $type );
1103 } catch ( \Exception $e ) {
1104 $format = '%s';
1105 }
1106
1107 // = and != can be shorthands for IN and NOT in for array values.
1108 if ( is_array( $value ) && '=' === $operator ) {
1109 $operator = 'IN';
1110 } elseif ( is_array( $value ) && '!=' === $operator ) {
1111 $operator = 'NOT IN';
1112 }
1113
1114 if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN', '>', '>=', '<', '<=' ), true ) ) {
1115 return false;
1116 }
1117
1118 if ( is_array( $value ) ) {
1119 $value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
1120 } else {
1121 $value = $db_util->format_object_value_for_db( $value, $type );
1122 }
1123
1124 if ( is_array( $value ) ) {
1125 $placeholder = array_fill( 0, count( $value ), $format );
1126 $placeholder = '(' . implode( ',', $placeholder ) . ')';
1127 } else {
1128 $placeholder = $format;
1129 }
1130
1131 $sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
1132
1133 return $sql;
1134 }
1135
1136 /**
1137 * Processes fields related to the orders table.
1138 *
1139 * @return void
1140 */
1141 private function process_orders_table_query_args(): void {
1142 $this->sanitize_status();
1143
1144 $fields = array_filter(
1145 array(
1146 'id',
1147 'status',
1148 'type',
1149 'currency',
1150 'tax_amount',
1151 'customer_id',
1152 'billing_email',
1153 'parent_order_id',
1154 'payment_method',
1155 'payment_method_title',
1156 'transaction_id',
1157 'ip_address',
1158 'user_agent',
1159 ),
1160 array( $this, 'arg_isset' )
1161 );
1162
1163 foreach ( $fields as $arg_key ) {
1164 $this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
1165 }
1166
1167 // customer_note allows empty string to match orders with no note, so it cannot use arg_isset (which skips '').
1168 if ( isset( $this->args['customer_note'] ) ) {
1169 $this->where[] = $this->where( $this->tables['orders'], 'customer_note', '=', $this->args['customer_note'], $this->mappings['orders']['customer_note']['type'] );
1170 }
1171
1172 if ( $this->arg_isset( 'parent_exclude' ) ) {
1173 $this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
1174 }
1175
1176 if ( $this->arg_isset( 'exclude' ) ) {
1177 $this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
1178 }
1179
1180 // 'customer' is a very special field.
1181 if ( $this->arg_isset( 'customer' ) ) {
1182 $customer_query = $this->generate_customer_query( $this->args['customer'] );
1183
1184 if ( $customer_query ) {
1185 $this->where[] = $customer_query;
1186 }
1187 }
1188
1189 // Handle total filtering with operators.
1190 if ( $this->arg_isset( 'total_amount' ) ) {
1191 $total_param = $this->args['total_amount'];
1192
1193 // If it's a simple number, convert to array format.
1194 if ( is_numeric( $total_param ) ) {
1195 $total_param = array(
1196 'value' => $total_param,
1197 'operator' => '=',
1198 );
1199 }
1200
1201 $total_query = $this->generate_total_query( (array) $total_param );
1202
1203 if ( $total_query ) {
1204 $this->where[] = $total_query;
1205 }
1206 }
1207 }
1208
1209 /**
1210 * Generate SQL conditions for the 'customer' query.
1211 *
1212 * @param array $values List of customer ids or emails.
1213 * @param string $relation 'OR' or 'AND' relation used to build the customer query.
1214 * @return string SQL to be used in a WHERE clause.
1215 */
1216 private function generate_customer_query( $values, string $relation = 'OR' ): string {
1217 $values = is_array( $values ) ? $values : array( $values );
1218 $ids = array();
1219 $emails = array();
1220 $pieces = array();
1221 foreach ( $values as $value ) {
1222 if ( is_array( $value ) ) {
1223 $sql = $this->generate_customer_query( $value, 'AND' );
1224 $pieces[] = $sql ? '(' . $sql . ')' : '';
1225 } elseif ( is_numeric( $value ) ) {
1226 $ids[] = absint( $value );
1227 } elseif ( is_string( $value ) && is_email( $value ) ) {
1228 $emails[] = sanitize_email( $value );
1229 } else {
1230 // Invalid query.
1231 $pieces[] = '1=0';
1232 }
1233 }
1234
1235 if ( $ids ) {
1236 $pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
1237 }
1238
1239 if ( $emails ) {
1240 $pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
1241 }
1242
1243 return $pieces ? implode( " $relation ", $pieces ) : '';
1244 }
1245
1246 /**
1247 * Generate SQL conditions for the 'total' query with operators.
1248 *
1249 * @param array $total_params Total query parameters with value, operator.
1250 * @return string SQL to be used in a WHERE clause.
1251 */
1252 private function generate_total_query( array $total_params ): string {
1253 if ( ! isset( $total_params['value'] ) ) {
1254 return '';
1255 }
1256
1257 $operator = $total_params['operator'] ?? '=';
1258 $value = $total_params['value'];
1259 $supported_operators = array( '=', '!=', '>', '>=', '<', '<=', 'BETWEEN', 'NOT BETWEEN' );
1260
1261 if ( ! in_array( $operator, $supported_operators, true ) ) {
1262 return '';
1263 }
1264
1265 // Handle between operators.
1266 if ( 'BETWEEN' === $operator || 'NOT BETWEEN' === $operator ) {
1267 if ( ! is_array( $value ) || count( $value ) !== 2 ) {
1268 return '';
1269 }
1270 $value1 = wc_format_decimal( $value[0], wc_get_price_decimals() );
1271 $value2 = wc_format_decimal( $value[1], wc_get_price_decimals() );
1272
1273 if ( 'BETWEEN' === $operator ) {
1274 return $this->where( $this->tables['orders'], 'total_amount', '>=', $value1, 'decimal' ) . ' AND ' . $this->where( $this->tables['orders'], 'total_amount', '<=', $value2, 'decimal' );
1275 } else {
1276 return '(' . $this->where( $this->tables['orders'], 'total_amount', '<', $value1, 'decimal' ) . ' OR ' . $this->where( $this->tables['orders'], 'total_amount', '>', $value2, 'decimal' ) . ')';
1277 }
1278 }
1279
1280 // Handle other operators - value must be a single number.
1281 if ( ! is_numeric( $value ) ) {
1282 return '';
1283 }
1284
1285 return $this->where( $this->tables['orders'], 'total_amount', $operator, wc_format_decimal( $value, wc_get_price_decimals() ), 'decimal' );
1286 }
1287
1288 /**
1289 * Processes fields related to the operational data table.
1290 *
1291 * @return void
1292 */
1293 private function process_operational_data_table_query_args(): void {
1294 $fields = array_filter(
1295 array(
1296 'created_via',
1297 'woocommerce_version',
1298 'prices_include_tax',
1299 'order_key',
1300 'discount_total_amount',
1301 'discount_tax_amount',
1302 'shipping_total_amount',
1303 'shipping_tax_amount',
1304 ),
1305 array( $this, 'arg_isset' )
1306 );
1307
1308 if ( ! $fields ) {
1309 return;
1310 }
1311
1312 $this->join(
1313 $this->tables['operational_data'],
1314 '',
1315 '',
1316 'inner',
1317 true
1318 );
1319
1320 foreach ( $fields as $arg_key ) {
1321 $this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
1322 }
1323 }
1324
1325 /**
1326 * Processes fields related to the addresses table.
1327 *
1328 * @return void
1329 */
1330 private function process_addresses_table_query_args(): void {
1331 global $wpdb;
1332
1333 foreach ( array( 'billing', 'shipping' ) as $address_type ) {
1334 $fields = array_filter(
1335 array(
1336 $address_type . '_first_name',
1337 $address_type . '_last_name',
1338 $address_type . '_company',
1339 $address_type . '_address_1',
1340 $address_type . '_address_2',
1341 $address_type . '_city',
1342 $address_type . '_state',
1343 $address_type . '_postcode',
1344 $address_type . '_country',
1345 $address_type . '_phone',
1346 ),
1347 array( $this, 'arg_isset' )
1348 );
1349
1350 if ( ! $fields ) {
1351 continue;
1352 }
1353
1354 $this->join(
1355 $this->tables['addresses'],
1356 $address_type,
1357 $wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1358 'inner',
1359 false
1360 );
1361
1362 foreach ( $fields as $arg_key ) {
1363 $column_name = str_replace( "{$address_type}_", '', $arg_key );
1364
1365 $this->where[] = $this->where(
1366 $address_type,
1367 $column_name,
1368 '=',
1369 $this->args[ $arg_key ],
1370 $this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
1371 );
1372 }
1373 }
1374 }
1375
1376 /**
1377 * Generates the ORDER BY clause.
1378 *
1379 * @return void
1380 */
1381 private function process_orderby(): void {
1382 // 'order' and 'orderby' vars.
1383 $order = $this->sanitize_order( $this->args['order'] ?? '' );
1384 $orderby = $this->sanitize_order_orderby( $this->args['orderby'] ?? 'none' );
1385
1386 // Set orderby to an empty array by default. This will also be used if sanitize_order_orderby received "none".
1387 $this->orderby = array();
1388
1389 if ( 'include' === $orderby || 'post__in' === $orderby ) {
1390 $ids = $this->args['id'] ?? $this->args['includes'];
1391 if ( empty( $ids ) ) {
1392 return;
1393 }
1394 $ids = array_map( 'absint', $ids );
1395 $this->orderby = array( "FIELD( {$this->tables['orders']}.id, " . implode( ',', $ids ) . ' )' );
1396 return;
1397 }
1398
1399 if ( is_array( $orderby ) ) {
1400 $meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : array();
1401 $orderby_array = array();
1402
1403 foreach ( $orderby as $_orderby => $order ) {
1404 if ( in_array( $_orderby, $meta_orderby_keys, true ) ) {
1405 $_orderby = $this->meta_query->get_orderby_clause_for_key( $_orderby );
1406 }
1407
1408 $orderby_array[] = "{$_orderby} {$order}";
1409 }
1410
1411 $this->orderby = $orderby_array;
1412 }
1413 }
1414
1415 /**
1416 * Generates the limits to be used in the LIMIT clause.
1417 *
1418 * @return void
1419 */
1420 private function process_limit(): void {
1421 $row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
1422 $page = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
1423 $offset = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );
1424
1425 // Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
1426 if ( false === $row_count || $row_count < -1 ) {
1427 return;
1428 }
1429
1430 if ( false === $offset && $row_count > -1 ) {
1431 $offset = (int) ( ( $page - 1 ) * $row_count );
1432 }
1433
1434 $this->limits = array( $offset, $row_count );
1435 }
1436
1437 /**
1438 * Checks if a query var is set (i.e. not one of the "skipped values").
1439 *
1440 * @param string $arg_key Query var.
1441 * @return bool TRUE if query var is set.
1442 */
1443 public function arg_isset( string $arg_key ): bool {
1444 return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
1445 }
1446
1447 /**
1448 * Runs the SQL query.
1449 *
1450 * @return void
1451 */
1452 private function run_query(): void {
1453 global $wpdb;
1454
1455 // Run query.
1456 $this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1457
1458 // Set max_num_pages and found_orders if necessary.
1459 if ( ( $this->arg_isset( 'no_found_rows' ) && $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
1460 return;
1461 }
1462
1463 $offset = (int) ( $this->limits[0] ?? 0 );
1464 $row_count = (int) ( $this->limits[1] ?? 0 );
1465
1466 if ( $row_count > 0 || $offset > 0 ) {
1467 $this->found_orders = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1468 $this->max_num_pages = $row_count > 0
1469 ? (int) ceil( $this->found_orders / $row_count )
1470 : 0;
1471 } else {
1472 $this->found_orders = count( $this->orders );
1473 }
1474 }
1475
1476 /**
1477 * Make some private available for backwards compatibility.
1478 *
1479 * @param string $name Property to get.
1480 * @return mixed
1481 */
1482 public function __get( string $name ) {
1483 switch ( $name ) {
1484 case 'found_orders':
1485 case 'found_posts':
1486 return $this->found_orders;
1487 case 'max_num_pages':
1488 return $this->max_num_pages;
1489 case 'posts':
1490 case 'orders':
1491 return $this->orders;
1492 case 'request':
1493 return $this->sql;
1494 default:
1495 break;
1496 }
1497 }
1498
1499 /**
1500 * Returns the value of one of the query arguments.
1501 *
1502 * @param string $arg_name Query var.
1503 * @return mixed
1504 */
1505 public function get( string $arg_name ) {
1506 return $this->args[ $arg_name ] ?? null;
1507 }
1508
1509 /**
1510 * Returns the name of one of the OrdersTableDatastore tables.
1511 *
1512 * @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
1513 * @return string The prefixed table name.
1514 * @throws \Exception When table ID is not found.
1515 */
1516 public function get_table_name( string $table_id = '' ): string {
1517 if ( ! isset( $this->tables[ $table_id ] ) ) {
1518 // Translators: %s is a table identifier.
1519 throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
1520 }
1521
1522 return $this->tables[ $table_id ];
1523 }
1524
1525 /**
1526 * Finds table and mapping information about a field or column.
1527 *
1528 * @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
1529 * @return false|array {
1530 * @type string $table Full table name where the field is located.
1531 * @type string $mapping_id Unprefixed table or mapping name.
1532 * @type string $field_name Name of the corresponding order field.
1533 * @type string $column Column in $table that corresponds to the field.
1534 * @type string $type Field type.
1535 * }
1536 */
1537 public function get_field_mapping_info( $field ) {
1538 global $wpdb;
1539
1540 $result = array(
1541 'table' => '',
1542 'mapping_id' => '',
1543 'field_name' => '',
1544 'column' => '',
1545 'column_type' => '',
1546 );
1547
1548 $mappings_to_search = array();
1549
1550 if ( false !== strstr( $field, '.' ) ) {
1551 list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );
1552
1553 $mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
1554 $mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;
1555
1556 if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
1557 if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
1558 $result['mapping_id'] = $mapping_or_table;
1559 $result['column'] = $field_name_or_col;
1560 } else {
1561 $mappings_to_search = array( $mapping_or_table );
1562 }
1563 }
1564 } else {
1565 $field_name_or_col = $field;
1566 $mappings_to_search = array_keys( $this->mappings );
1567 }
1568
1569 foreach ( $mappings_to_search as $mapping_id ) {
1570 foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
1571 if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
1572 $result['mapping_id'] = $mapping_id;
1573 $result['column'] = $column_name;
1574 break 2;
1575 }
1576 }
1577 }
1578
1579 if ( ! $result['mapping_id'] || ! $result['column'] ) {
1580 return false;
1581 }
1582
1583 $field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];
1584
1585 $result['field_name'] = $field_info['name'];
1586 $result['column_type'] = $field_info['type'];
1587 $result['table'] = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
1588 ? $this->tables['addresses']
1589 : $this->tables[ $result['mapping_id'] ];
1590
1591 return $result;
1592 }
1593
1594 /**
1595 * Return the query args that were used to initialize the query.
1596 *
1597 * @since 9.8.0
1598 * @return array Query args.
1599 */
1600 public function get_query_args(): array {
1601 return $this->query_args;
1602 }
1603 }
1604