PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / abstract-collection.php

abstract-collection.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/abstract-collection.php

1,740 lines 57.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use ArrayIterator;
6 use Countable;
7 use IteratorAggregate;
8 use stdClass;
9 use StoreEngine\Classes\Exceptions\StoreEngineInvalidArgumentException;
10 use WP_Meta_Query;
11 use wpdb;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 #[\AllowDynamicProperties]
18 abstract class AbstractCollection implements IteratorAggregate, Countable {
19
20 protected string $table = '';
21
22 protected string $object_type = 'data';
23
24 protected string $meta_type = '';
25
26 protected string $hook_prefix = '';
27
28 protected string $primary_key = 'ID';
29
30 protected string $orderBy = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
31
32 protected string $order = 'DESC';
33
34 protected string $parent_key = 'parent';
35
36 protected string $menu_order = 'menu_order';
37
38 protected bool $returnNative; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
39
40 protected string $returnType = OBJECT; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
41
42 protected bool $cache_query = true;
43
44 /**
45 * Table name without db prefix.
46 *
47 * wp_storeengine_orders -> storeengine_orders
48 * wp_storeengine_product_price -> storeengine_product_price
49 * wp_storeengine_orders -> storeengine_orders
50 * wp_storeengine_licenses -> storeengine_licenses
51 * wp_storeengine_payment_tokens -> storeengine_payment_tokens
52 *
53 * @var string|null
54 */
55 protected ?string $cache_group = null;
56
57 protected ?string $global_variable_name = null;
58
59 /**
60 * Stores the ->query_vars state like md5(serialize( $this->query_vars ) ) so we know
61 * whether we have to re-parse because something has changed
62 *
63 * @var bool|string
64 */
65 private $query_vars_hash = false;
66
67 protected ?array $results = null;
68
69 protected int $result_count = 0;
70
71 /**
72 * Index of the current item in the loop.
73 *
74 * @var int
75 */
76 public int $current_result = - 1;
77
78 /**
79 * Whether the caller is before the loop.
80 *
81 * @var bool
82 */
83 public bool $before_loop = true;
84
85 /**
86 * Whether the loop has started and the caller is in the loop.
87 *
88 * @var bool
89 */
90 public bool $in_the_loop = false;
91
92 /**
93 * The current result.
94 *
95 * This property does not get populated when the `fields` argument is set to
96 * `ids` or `id=>parent`.
97 *
98 * @var mixed
99 */
100 protected $result = null;
101
102 protected int $found_results = 0;
103
104 protected int $max_num_pages = 0;
105
106 protected ?int $page = null;
107
108 protected ?int $per_page = null;
109
110 protected ?bool $nopaging = null;
111
112 /**
113 * Signifies whether the current query is for a single post.
114 *
115 * @var bool
116 */
117 public bool $is_single = false;
118
119 /**
120 * SQL for the database query.
121 *
122 * @var string
123 */
124 public string $request;
125
126 public ?array $query = null;
127
128 protected array $query_vars = [];
129
130 protected array $must_where = [];
131
132 protected bool $need_setup = true;
133
134 /**
135 * @var wpdb
136 */
137 protected $wpdb;
138
139 /**
140 * Constructor.
141 *
142 * Sets up the WordPress query, if parameter is not empty.
143 *
144 * @param string|array $query URL query string or array of vars.
145 *
146 * @throws StoreEngineInvalidArgumentException
147 * @see \WP_Query::parse_query() for all available arguments.
148 */
149 public function __construct( $query = '' ) {
150 $this->setup();
151
152 if ( ! empty( $query ) ) {
153 $this->query( $query );
154 }
155 }
156
157 final protected function setup() {
158 global $wpdb;
159
160 if ( ! $this->need_setup ) {
161 return;
162 }
163
164 $this->need_setup = false;
165 $this->wpdb = $wpdb;
166
167 $nativeTypes = [ OBJECT, ARRAY_A ];
168 $this->returnNative = in_array( strtoupper( $this->returnType ), $nativeTypes, true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
169 $this->returnType = ! $this->returnNative ? $this->returnType : strtoupper( $this->returnType ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
170 $table = str_replace( $wpdb->prefix, '', $this->table );
171 $this->object_type = $this->object_type ?: trim( str_replace( 'storeengine_', '', $table ) );
172 $this->hook_prefix = 'storeengine/collection/' . str_replace( '_', '/', $table );
173 $this->table = $wpdb->prefix . $table;
174
175 if ( ! $this->cache_group ) {
176 $this->cache_group = $table;
177 }
178
179 if ( ! $this->global_variable_name ) {
180 $this->global_variable_name = trim( str_replace( [ '-' ], '_', $this->object_type ) );
181 }
182 }
183
184 /**
185 * Sets up the query by parsing query string.
186 *
187 * @param string|array $query URL query string or array of query arguments.
188 *
189 * @throws StoreEngineInvalidArgumentException
190 * @see WP_Query::parse_query() for all available arguments.
191 */
192 public function query( $query ) {
193 $this->init();
194 $this->query = wp_parse_args( $query );
195 $this->query_vars = $this->query;
196
197 $this->prepare_results();
198 }
199
200 /**
201 * Initiates object properties and sets default values.
202 */
203 public function init() {
204 unset( $this->results );
205 unset( $this->query );
206 $this->query_vars = [];
207 $this->result_count = 0;
208 $this->current_result = - 1;
209 $this->in_the_loop = false;
210 $this->before_loop = true;
211 unset( $this->request );
212 unset( $this->result );
213 $this->found_results = 0;
214 $this->max_num_pages = 0;
215
216 // init flags
217 $this->is_single = false;
218 }
219
220 /**
221 * Reparses the query vars.
222 */
223 public function parse_query_vars() {
224 $this->parse_query();
225 }
226
227 /**
228 * Fills in the query variables, which do not exist within the parameter.
229 *
230 * @param array $query_vars Defined query variables.
231 *
232 * @return array Complete query variables with undefined ones filled in empty.
233 */
234 public function fill_query_vars( array $query_vars ): array {
235 $keys = [ 'error', $this->primary_key, 'fields', $this->menu_order ];
236
237 foreach ( $keys as $key ) {
238 if ( ! isset( $query_vars[ $key ] ) ) {
239 $query_vars[ $key ] = '';
240 }
241 }
242
243 if ( ! isset( $query_vars['where'] ) ) {
244 $query_vars['where'] = [];
245 }
246
247 if ( ! is_array( $query_vars['where'] ) ) {
248 $query_vars['where'] = [];
249 }
250
251 return $query_vars;
252 }
253
254 protected function get_default_per_page(): int {
255 $per_page = get_option( 'posts_per_page', 10 );
256 $per_page = apply_filters_deprecated( "storeengine/$this->object_type/collection/per_page", [ $per_page ], '1.6.9', $this->hook_prefix . '/per_page' );
257 $per_page = apply_filters( $this->hook_prefix . '/per_page', $per_page ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
258
259 return absint( $per_page );
260 }
261
262 protected function parse_query( $query = '' ) {
263 if ( ! empty( $query ) ) {
264 $this->init();
265 $this->query = wp_parse_args( $query );
266 } elseif ( ! isset( $this->query ) ) {
267 $this->query = $this->query_vars;
268 }
269
270 $this->query = wp_parse_args( $this->query, [
271 $this->primary_key => '',
272 'fields' => '',
273 'count' => '',
274 'per_page' => $this->get_default_per_page(),
275 'page' => 1,
276 'offset' => null,
277 'where' => [],
278 'no_found_rows' => false,
279 'orderby' => $this->orderBy ?: $this->primary_key,
280 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
281 'order' => $this->order,
282 'suppress_filters' => false,
283 ] );
284 $this->query_vars = $this->fill_query_vars( $this->query );
285
286 if ( ! empty( $this->must_where ) ) {
287 $this->query_vars['where'] = array_merge( $this->must_where, $this->query_vars['where'] );
288 }
289
290 $args = &$this->query_vars;
291 $this->query_vars_changed = true;
292
293 if ( 'count' === $args['fields'] ) {
294 $args['count'] = true;
295 }
296
297 if ( $args['count'] && 'count' !== $args['fields'] ) {
298 $args['fields'] = 'count';
299 $args['count'] = true;
300 }
301
302 if ( $args['count'] ) {
303 $args[ $this->primary_key ] = '';
304 $args['per_page'] = - 1;
305 $args['no_found_rows'] = true;
306 }
307
308 if ( ! is_scalar( $args[ $this->primary_key ] ) || (int) $args[ $this->primary_key ] < 0 ) {
309 $args[ $this->primary_key ] = 0;
310 $args['error'] = '404';
311 } elseif ( $args[ $this->primary_key ] ) {
312 $args[ $this->primary_key ] = (int) $args[ $this->primary_key ];
313 }
314
315 if ( $args[ $this->primary_key ] ) {
316 $this->is_single = true;
317 }
318 }
319
320 /**
321 * Retrieves the value of a query variable.
322 *
323 * @since 1.6.9
324 *
325 * @param string $query_var Query variable key.
326 * @param mixed $default_value Optional. Value to return if the query variable is not set.
327 * Default empty string.
328 * @return mixed Contents of the query variable.
329 */
330 public function get( string $query_var, $default_value = '' ) {
331 if ( isset( $this->query_vars[ $query_var ] ) ) {
332 return $this->query_vars[ $query_var ];
333 }
334
335 return $default_value;
336 }
337
338 /**
339 * Sets the value of a query variable.
340 *
341 * @since 1.6.9
342 *
343 * @param string $query_var Query variable key.
344 * @param mixed $value Query variable value.
345 */
346 public function set( string $query_var, $value ) {
347 $this->query_vars[ $query_var ] = $value;
348 }
349
350 /**
351 * @return AbstractEntity[]|int[]|array[]|object[]|stdClass[]|null
352 */
353 public function get_results(): ?array {
354 return $this->results;
355 }
356
357 /**
358 * Prepare results, build & execute query.
359 *
360 * If object mapping configured, it will try to map raw db data into php class instance too.
361 *
362 * @return AbstractEntity[]|int[]|array[]|object[]|stdClass[]
363 * @throws StoreEngineInvalidArgumentException
364 */
365 protected function prepare_results(): array {
366 global $wpdb;
367
368 $this->parse_query();
369
370 /**
371 * Fires after the query variable object is created, but before the actual query is run.
372 *
373 * Note: If using conditional tags, use the method versions within the passed instance
374 * (e.g. $this->is_main_query() instead of is_main_query()). This is because the functions
375 * like is_main_query() test against the global $wp_query instance, not the passed one.
376 *
377 * @param self $query The WP_Query instance (passed by reference).
378 */
379 do_action_ref_array( $this->hook_prefix . '/pre_get_results', [ &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
380 do_action_deprecated( $this->cache_group . '_pre_get_results', [ &$this ], '1.6.9', $this->hook_prefix . '/pre_get_results' );
381
382 // Shorthand.
383 $args = &$this->query_vars;
384
385 // Fill again in case 'pre_get_posts' unset some vars.
386 $args = $this->fill_query_vars( $args );
387
388 $this->meta_query = new WP_Meta_Query();
389 $this->meta_query->parse_query_vars( $args );
390
391 $args['suppress_filters'] = (bool) ( $args['suppress_filters'] ?? false );
392 $args['no_found_rows'] = (bool) ( $args['no_found_rows'] ?? false );
393
394 // Set a flag if a 'pre_get_posts' hook changed the query vars.
395 $hash = md5( maybe_serialize( $this->query_vars ) );
396 if ( $hash !== $this->query_vars_hash ) {
397 $this->query_vars_changed = true;
398 $this->query_vars_hash = $hash;
399 }
400
401 unset( $hash );
402
403 // First let's clear some variables.
404 $page = max( 1, absint( $args['page'] ?? ( $args['paged'] ?? 1 ) ) );
405 $args['per_page'] = intval( $args['per_page'] ?? $this->get_default_per_page() );
406 $args['nopaging'] = - 1 === $args['per_page'];
407
408 // If true, forcibly turns off SQL_CALC_FOUND_ROWS even when limits are present.
409 if ( isset( $query_vars['no_found_rows'] ) ) {
410 $args['no_found_rows'] = (bool) $query_vars['no_found_rows'];
411 } else {
412 $args['no_found_rows'] = false;
413 }
414
415 $this->page = $page;
416 $this->per_page = $args['per_page'];
417 $this->nopaging = $args['nopaging'];
418
419 // Prepare the query.
420 $distinct = '';
421 $where = '';
422 $limits = $this->get_limit_sql( $args, $page );
423 $join = '';
424 $groupby = '';
425 $orderby = $this->get_orderby_sql( $args );
426
427 $allFields = "{$this->table}.*";
428
429 switch ( $args['fields'] ) {
430 case 'ids':
431 $fields = "{$this->table}.$this->primary_key";
432 break;
433 case 'count':
434 $fields = "COUNT({$this->table}.$this->primary_key)";
435 break;
436 case 'id=>parent':
437 $fields = "{$this->table}.$this->primary_key, {$this->table}.{$this->parent_key}";
438 break;
439 default:
440 $fields = "{$this->table}.*";
441 }
442
443 if ( ! empty( $args[ $this->primary_key ] ) ) {
444 $args['where'][] = [
445 'key' => $this->primary_key,
446 'value' => $args[ $this->primary_key ],
447 'compare' => '=',
448 'type' => 'UNSIGNED',
449 ];
450 }
451
452 if ( ! empty( $args['where'] ) && is_array( $args['where'] ) ) {
453 $where = trim( $this->generate_conditions( $args['where'], $params ) );
454 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
455 $where = $wpdb->prepare( $where, $params );
456 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
457 }
458
459 if ( $this->meta_type && ! empty( $this->meta_query->queries ) ) {
460 $groupby = "{$this->table}.{$this->primary_key}";
461 $clauses = $this->meta_query->get_sql( $this->meta_type, $this->table, $this->primary_key, $this );
462 $join .= $clauses['join'];
463 $where .= $clauses['where'];
464 }
465
466 $pieces = [ 'where', 'groupby', 'join', 'orderby', 'distinct', 'fields', 'limits' ];
467
468 /*
469 * Apply post-paging filters on where and join. Only plugins that
470 * manipulate paging queries should use these hooks.
471 */
472 if ( ! $args['suppress_filters'] ) {
473 /**
474 * Filters the WHERE clause of the query.
475 *
476 * Specifically for manipulating paging queries.
477 *
478 * @param string $where The WHERE clause of the query.
479 * @param self $query The self instance (passed by reference).
480 */
481 $where = apply_filters_ref_array( "{$this->hook_prefix}/where_paged", [ $where, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
482
483 /**
484 * Filters the GROUP BY clause of the query.
485 *
486 * @param string $groupby The GROUP BY clause of the query.
487 * @param self $query The self instance (passed by reference).
488 */
489 $groupby = apply_filters_ref_array( "{$this->hook_prefix}/groupby", [ $groupby, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
490
491 /**
492 * Filters the JOIN clause of the query.
493 *
494 * Specifically for manipulating paging queries.
495 *
496 * @param string $join The JOIN clause of the query.
497 * @param self $query The self instance (passed by reference).
498 */
499 $join = apply_filters_ref_array( "{$this->hook_prefix}/join_paged", [ $join, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
500
501 /**
502 * Filters the ORDER BY clause of the query.
503 *
504 * @param string $orderby The ORDER BY clause of the query.
505 * @param self $query The self instance (passed by reference).
506 */
507 $orderby = apply_filters_ref_array( "{$this->hook_prefix}/orderby", [ $orderby, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
508
509 /**
510 * Filters the DISTINCT clause of the query.
511 *
512 * @param string $distinct The DISTINCT clause of the query.
513 * @param self $query The self instance (passed by reference).
514 */
515 $distinct = apply_filters_ref_array( "{$this->hook_prefix}/distinct", [ $distinct, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
516
517 /**
518 * Filters the LIMIT clause of the query.
519 *
520 * @param string $limits The LIMIT clause of the query.
521 * @param self $query The self instance (passed by reference).
522 */
523 $limits = apply_filters_ref_array( "{$this->hook_prefix}/limits", [ $limits, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
524
525 /**
526 * Filters the SELECT clause of the query.
527 *
528 * @param string $fields The SELECT clause of the query.
529 * @param self $query The self instance (passed by reference).
530 */
531 $fields = apply_filters_ref_array( "{$this->hook_prefix}/fields", [ $fields, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
532
533 /**
534 * Filters all query clauses at once, for convenience.
535 *
536 * Covers the WHERE, GROUP BY, JOIN, ORDER BY, DISTINCT,
537 * fields (SELECT), and LIMIT clauses.
538 *
539 * @param string[] $clauses {
540 * Associative array of the clauses for the query.
541 *
542 * @type string $where The WHERE clause of the query.
543 * @type string $groupby The GROUP BY clause of the query.
544 * @type string $join The JOIN clause of the query.
545 * @type string $orderby The ORDER BY clause of the query.
546 * @type string $distinct The DISTINCT clause of the query.
547 * @type string $fields The SELECT clause of the query.
548 * @type string $limits The LIMIT clause of the query.
549 * }
550 *
551 * @param self $query The self instance (passed by reference).
552 */
553 $clauses = (array) apply_filters_ref_array( "{$this->hook_prefix}/clauses", [ compact( $pieces ), &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
554
555 $where = $clauses['where'] ?? '';
556 $groupby = $clauses['groupby'] ?? '';
557 $join = $clauses['join'] ?? '';
558 $orderby = $clauses['orderby'] ?? '';
559 $distinct = $clauses['distinct'] ?? '';
560 $fields = $clauses['fields'] ?? '';
561 $limits = $clauses['limits'] ?? '';
562 }
563
564 if ( ! empty( $groupby ) ) {
565 $groupby = 'GROUP BY ' . $groupby;
566 }
567 if ( ! empty( $orderby ) ) {
568 $orderby = 'ORDER BY ' . $orderby;
569 }
570
571 $found_rows = '';
572 if ( ! $args['no_found_rows'] && ! empty( $limits ) ) {
573 $found_rows = 'SQL_CALC_FOUND_ROWS';
574 }
575
576 $where_clause = $where ? 'WHERE 1=1 AND ' . $where : '';
577
578 /**
579 * Beginning of the string is on a new line to prevent leading whitespace.
580 *
581 * The additional indentation of subsequent lines is to ensure the SQL
582 * queries are identical to those generated when splitting queries. This
583 * improves caching of the query by ensuring the same cache key is
584 * generated for the same database queries functionally.
585 *
586 * See https://core.trac.wordpress.org/ticket/56841.
587 * See https://github.com/WordPress/wordpress-develop/pull/6393#issuecomment-2088217429
588 *
589 * @noinspection SqlConstantExpression
590 */
591 $old_request =
592 "SELECT $found_rows $distinct $fields
593 FROM {$this->table} $join
594 {$where_clause}
595 $groupby
596 $orderby
597 $limits;";
598
599 if ( 'count' === $args['fields'] ) {
600 $count_field = str_replace( 'COUNT(', 'COUNT(' . $distinct . ' ', $fields );
601 $old_request =
602 "SELECT $found_rows $count_field
603 FROM {$this->table} $join
604 {$where_clause}
605 $groupby
606 $orderby
607 $limits;";
608 }
609
610 $this->request = $old_request;
611
612 if ( ! $args['suppress_filters'] ) {
613 /**
614 * Filters the completed SQL query before sending.
615 *
616 * @param string $request The complete SQL query.
617 * @param self $query The WP_Query instance (passed by reference).
618 */
619 $this->request = apply_filters_ref_array( "{$this->hook_prefix}/request", [ $this->request, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
620 }
621
622 /**
623 * Filters the posts array before the query takes place.
624 *
625 * Return a non-null value to bypass WordPress' default post queries.
626 *
627 * Filtering functions that require pagination information are encouraged to set
628 * the `found_posts` and `max_num_pages` properties of the WP_Query object,
629 * passed to the filter by reference. If WP_Query does not perform a database
630 * query, it will not have enough information to generate these values itself.
631 *
632 * @param object[]|array[]|int[]|null $posts Return an array of result data to short-circuit WP's query,
633 * or null to allow WP to run its normal queries.
634 * @param self $query The WP_Query instance (passed by reference).
635 */
636 $this->results = apply_filters_ref_array( "{$this->hook_prefix}/pre_query", [ null, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
637
638 /*
639 * Ensure the ID database query is able to be-cached.
640 *
641 * Random queries are expected to have unpredictable results and
642 * cannot be cached. Note the space before `RAND` in the string
643 * search, that to ensure against a collision with another
644 * function.
645 *
646 * If `$fields` has been modified by the `posts_fields`,
647 * `posts_fields_request`, `post_clauses` or `posts_clauses_request`
648 * filters, then caching is disabled to prevent caching collisions.
649 */
650 $id_query_is_cacheable = ! str_contains( strtoupper( $orderby ), ' RAND(' );
651
652 $cacheable_field_values = [
653 "{$this->table}.*",
654 "{$this->table}.{$this->primary_key}",
655 "COUNT({$this->table}.$this->primary_key)",
656 ];
657
658 if ( ! in_array( $fields, $cacheable_field_values, true ) ) {
659 $id_query_is_cacheable = false;
660 }
661
662 $cache_key = '';
663 $cache_found = false;
664
665 if ( $this->cache_query && $id_query_is_cacheable ) {
666 $new_request = str_replace( $fields, "{$this->table}.*", $this->request );
667 $cache_key = $this->generate_cache_key( $args, $new_request );
668
669 if ( null === $this->results ) {
670 $cached_results = wp_cache_get( $cache_key, $this->cache_group . '-queries', false, $cache_found );
671
672 if ( $cached_results ) {
673 $result_ids = array_map( 'intval', $cached_results['results'] );
674
675 $this->result_count = count( $result_ids );
676 $this->found_results = $cached_results['found_results'];
677 $this->max_num_pages = $cached_results['max_num_pages'];
678
679 if ( 'ids' === $args['fields'] || 'count' === $args['fields'] ) {
680 $this->results = $result_ids;
681
682 return $this->results;
683 } elseif ( 'id=>parent' === $args['fields'] ) {
684 $this->_prime_result_parent_id_caches( $result_ids );
685
686 $result_parent_cache_keys = [];
687 foreach ( $result_ids as $item_id ) {
688 $result_parent_cache_keys[] = $this->object_type . '_parent:' . $item_id;
689 }
690
691 /** @var int[] $result_parents */
692 $result_parents = wp_cache_get_multiple( $result_parent_cache_keys, $this->cache_group );
693
694 foreach ( $result_parents as $cache_key => $result_parent ) {
695 $obj = new stdClass();
696 $obj->ID = (int) str_replace( $this->object_type . '_parent:', '', $cache_key );
697 $obj->{$this->parent_key} = (int) $result_parent;
698
699 $this->results[] = $obj;
700 }
701
702 return $result_parents;
703 } else {
704 $this->_prime_item_caches( $result_ids );
705
706 $this->results = [];
707 foreach ( $result_ids as $result ) {
708 $this->results[] = $this->map_result( $result );
709 }
710 }
711 }
712 }
713 }
714
715 if ( 'count' === $args['fields'] ) {
716 if ( null === $this->results ) {
717 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
718 $this->results = $wpdb->get_col( $this->request );
719 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
720 }
721
722 $this->results = array_map( 'intval', $this->results );
723 $this->result_count = count( $this->results );
724 $this->set_found_results( $args, $limits );
725
726 if ( $this->cache_query && $id_query_is_cacheable ) {
727 $cache_value = [
728 'results' => $this->results,
729 'found_results' => 0,
730 'max_num_pages' => 0,
731 ];
732
733 wp_cache_set( $cache_key, $cache_value, $this->cache_group . '-queries' );
734 }
735
736 return $this->results;
737 }
738
739 if ( 'ids' === $args['fields'] ) {
740 if ( null === $this->results ) {
741 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
742 $this->results = $wpdb->get_col( $this->request );
743 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
744 }
745
746 $this->results = array_map( 'intval', $this->results );
747 $this->result_count = count( $this->results );
748 $this->set_found_results( $args, $limits );
749
750 if ( $this->cache_query && $id_query_is_cacheable ) {
751 $cache_value = [
752 'results' => $this->results,
753 'found_results' => $this->found_results,
754 'max_num_pages' => $this->max_num_pages,
755 ];
756
757 wp_cache_set( $cache_key, $cache_value, $this->cache_group . '-queries' );
758 }
759
760 return $this->results;
761 }
762
763 if ( 'id=>parent' === $args['fields'] ) {
764 if ( null === $this->results ) {
765 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
766 $this->results = $wpdb->get_results( $this->request );
767 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
768 }
769
770 $this->result_count = count( $this->results );
771 $this->set_found_results( $args, $limits );
772
773 /** @var int[] $result_parents */
774 $result_parents = [];
775 $result_ids = [];
776 $result_parents_cache = [];
777
778 foreach ( $this->results as $key => $item ) {
779 $this->results[ $key ]->{$this->primary_key} = (int) $item->{$this->primary_key};
780 $this->results[ $key ]->{$this->parent_key} = (int) $item->{$this->parent_key};
781
782 $result_parents[ (int) $item->{$this->primary_key} ] = (int) $item->{$this->parent_key};
783 $result_ids[] = (int) $item->{$this->primary_key};
784
785 $result_parents_cache[ $this->cache_group . '_parent:' . $item->{$this->primary_key} ] = (int) $item->{$this->parent_key};
786 }
787
788 // Prime post parent caches, so that on second run, there is not another database query.
789 wp_cache_add_multiple( $result_parents_cache, $this->cache_group );
790
791 if ( $this->cache_query && $id_query_is_cacheable ) {
792 $cache_value = [
793 'results' => $result_ids,
794 'found_results' => $this->found_results,
795 'max_num_pages' => $this->max_num_pages,
796 ];
797
798 wp_cache_set( $cache_key, $cache_value, $this->cache_group . '-queries' );
799 }
800
801 return $result_parents;
802 }
803
804 $is_unfiltered_query = $old_request === $this->request && $allFields === $fields;
805
806 if ( null === $this->results ) {
807 $split_the_query = ( $is_unfiltered_query && ( wp_using_ext_object_cache() || ( ! empty( $limits ) && $args['per_page'] < 500 ) ) );
808
809 /**
810 * Filters whether to split the query.
811 *
812 * Splitting the query will cause it to fetch just the IDs of the found posts
813 * (and then individually fetch each post by ID), rather than fetching every
814 * complete row at once. One massive result vs. many small results.
815 *
816 * @param bool $split_the_query Whether or not to split the query.
817 * @param self $query The WP_Query instance.
818 * @param string $old_request The complete SQL query before filtering.
819 * @param string[] $clauses {
820 * Associative array of the clauses for the query.
821 *
822 * @type string $where The WHERE clause of the query.
823 * @type string $groupby The GROUP BY clause of the query.
824 * @type string $join The JOIN clause of the query.
825 * @type string $orderby The ORDER BY clause of the query.
826 * @type string $distinct The DISTINCT clause of the query.
827 * @type string $fields The SELECT clause of the query.
828 * @type string $limits The LIMIT clause of the query.
829 * }
830 */
831 $split_the_query = apply_filters( "{$this->hook_prefix}/split_query", $split_the_query, $this, $old_request, compact( $pieces ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
832
833 if ( $split_the_query ) {
834 // First get the IDs and then fill in the objects.
835
836 $where_clause = $where ? 'WHERE 1=1 AND ' . $where : '';
837
838 /**
839 * Beginning of the string is on a new line to prevent leading whitespace. See https://core.trac.wordpress.org/ticket/56841.
840 *
841 * @noinspection SqlConstantExpression
842 */
843 $this->request =
844 "SELECT $found_rows $distinct {$this->table}.{$this->primary_key}
845 FROM {$this->table} $join
846 {$where_clause}
847 $groupby
848 $orderby
849 $limits;";
850
851 /**
852 * Filters the Post IDs SQL request before sending.
853 *
854 * @param string $request The post ID request.
855 * @param self $query The WP_Query instance.
856 */
857 $this->request = apply_filters( "{$this->hook_prefix}/request_ids", $this->request, $this ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
858
859 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
860 $result_ids = $wpdb->get_col( $this->request );
861 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
862
863 if ( $result_ids ) {
864 $this->results = $result_ids;
865 $this->set_found_results( $args, $limits );
866 $this->_prime_item_caches( $result_ids );
867 } else {
868 $this->results = [];
869 }
870 } else {
871 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
872 $this->results = $wpdb->get_results( $this->request );
873 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- query prepared above.
874 $this->set_found_results( $args, $limits );
875 }
876 }
877
878 $raw_results = $this->results;
879 // Convert to Entity objects.
880 if ( $this->results ) {
881 $results = [];
882 foreach ( $this->results as $result ) {
883 if ( is_scalar( $result ) ) {
884 $results[] = $this->map_result( absint( $result ) );
885 } else {
886 $results[] = $this->map_result( $result );
887 }
888 }
889
890 $this->results = $results;
891 }
892
893 if ( $this->cache_query && $id_query_is_cacheable && ! $cache_found ) {
894 $result_ids = $raw_results;
895 if (
896 ! empty( $raw_results ) &&
897 (
898 ! empty( $raw_results[0] ) &&
899 (
900 ( is_object( $raw_results[0] ) && isset( $raw_results[0]->{$this->primary_key} ) ) ||
901 ( is_array( $raw_results[0] ) && isset( $raw_results[0][ $this->primary_key ] ) )
902 )
903 )
904 ) {
905 $result_ids = wp_list_pluck( $raw_results, $this->primary_key );
906 }
907
908 $cache_value = [
909 'results' => $result_ids,
910 'found_results' => $this->found_results,
911 'max_num_pages' => $this->max_num_pages,
912 ];
913
914 wp_cache_set( $cache_key, $cache_value, $this->cache_group . '-queries' );
915 }
916
917 if ( $this->results ) {
918 $this->result_count = count( $this->results );
919 $this->result = reset( $this->results );
920 } else {
921 $this->results = [];
922 $this->result_count = 0;
923 }
924
925 return $this->results;
926 }
927
928 protected function get_return_type( $result ) {
929 if ( ! $this->returnNative && class_exists( $this->returnType ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase, WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
930 return $this->returnType; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase, WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
931 }
932
933 return false;
934 }
935
936 protected function map_result( $result ) {
937 $className = $this->get_return_type( $result );
938
939 return $className ? new $className( $result ) : $result;
940 }
941
942 /**
943 * Sets up the amount of found posts and the number of pages (if limit clause was used)
944 * for the current query.
945 *
946 * @param array $args Query variables.
947 * @param string $limits LIMIT clauses of the query.
948 *
949 * @global wpdb $wpdb WordPress database abstraction object.
950 */
951 final protected function set_found_results( array $args, string $limits ) {
952 global $wpdb;
953
954 /*
955 * Bail if posts is an empty array. Continue if posts is an empty string,
956 * null, or false to accommodate caching plugins that fill posts later.
957 */
958 if ( $args['no_found_rows'] || ( is_array( $this->results ) && ! $this->results ) ) {
959 return;
960 }
961
962 if ( ! empty( $limits ) ) {
963 /**
964 * Filters the query to run for retrieving the found posts.
965 *
966 * @param string $found_posts_query The query to run to find the found posts.
967 * @param self $query The self instance (passed by reference).
968 */
969 $found_results_query = apply_filters_ref_array( "{$this->hook_prefix}/found_results_query", [ 'SELECT FOUND_ROWS()', &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
970
971 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
972 $this->found_results = (int) $wpdb->get_var( $found_results_query );
973 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
974 } else {
975 if ( is_array( $this->results ) ) {
976 $this->found_results = count( $this->results );
977 } else {
978 if ( null === $this->results ) {
979 $this->found_results = 0;
980 } else {
981 $this->found_results = 1;
982 }
983 }
984 }
985
986 /**
987 * Filters the number of found posts for the query.
988 *
989 * @param int $found_posts The number of posts found.
990 * @param self $query The self instance (passed by reference).
991 */
992 $this->found_results = (int) apply_filters_ref_array( "{$this->hook_prefix}/found_results", [ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
993 $this->found_results,
994 &$this
995 ] );
996
997 if ( ! empty( $limits ) ) {
998 $this->max_num_pages = (int) ceil( $this->found_results / $args['per_page'] );
999 }
1000 }
1001
1002 /**
1003 * Sets up the next post and iterate current post index.
1004 *
1005 * @return mixed Next post.
1006 */
1007 public function next_result() {
1008 ++ $this->current_result;
1009
1010 $this->result = $this->results[ $this->current_result ] ?? null;
1011
1012 return $this->result;
1013 }
1014
1015 /**
1016 * Sets up the current post.
1017 *
1018 * Retrieves the next post, sets up the post, sets the 'in the loop'
1019 * property to true.
1020 *
1021 * @global mixed $item Global post object.
1022 */
1023 public function the_result() {
1024 if ( ! $this->global_variable_name ) {
1025 return;
1026 }
1027 if ( isset( $GLOBALS[ $this->global_variable_name ] ) ) {
1028 unset( $GLOBALS[ $this->global_variable_name ] );
1029 }
1030
1031 if ( ! $this->in_the_loop ) {
1032 // Only prime the post cache for queries limited to the ID field.
1033 $item_ids = array_filter( $this->results, 'is_numeric' );
1034 // Exclude any falsey values, such as 0.
1035 $item_ids = array_filter( $item_ids );
1036 if ( $item_ids ) {
1037 $this->_prime_item_caches( $item_ids );
1038 }
1039 }
1040
1041 $this->in_the_loop = true;
1042 $this->before_loop = false;
1043
1044 if ( - 1 === $this->current_result ) { // Loop has just started.
1045 /**
1046 * Fires once the loop is started.
1047 *
1048 * @param self $query The WP_Query instance (passed by reference).
1049 */
1050 do_action_ref_array( $this->hook_prefix . '/loop_start', [ &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1051 do_action_deprecated( $this->cache_group . '_loop_start', [ &$this ], '1.6.9', $this->hook_prefix . '/loop_start' );
1052 }
1053
1054 $GLOBALS[ $this->global_variable_name ] = $this->next_result(); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- variable contains prefixed string.
1055 $this->setup_result_data( $GLOBALS[ $this->global_variable_name ] );
1056 }
1057
1058 /**
1059 * After looping through a nested query, this function
1060 * restores the $post global to the current post in this query.
1061 *
1062 * @global object $post Global post object.
1063 */
1064 public function reset_result_data() {
1065 if ( ! empty( $this->result ) ) {
1066 $GLOBALS[ $this->cache_group . '_result' ] = $this->result; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- variable contains prefixed string.
1067 $this->setup_result_data( $this->result );
1068 }
1069 }
1070
1071 /**
1072 * Sets up global result data.
1073 *
1074 * @param object|int $result WP_Post instance or Post ID/object.
1075 *
1076 * @return true True when finished.
1077 */
1078 public function setup_result_data( $result ): bool {
1079 /**
1080 * Fires once the result data has been set up.
1081 *
1082 * @param object $result The Post object (passed by reference).
1083 * @param self $query The current Query object (passed by reference).
1084 */
1085 do_action_ref_array( $this->hook_prefix . '/the_result', [ &$result, &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1086 do_action_deprecated( $this->cache_group . '_the_result', [ &$this ], '1.6.9', $this->hook_prefix . '/the_result' );
1087
1088 return true;
1089 }
1090
1091 /**
1092 * Determines whether there are more posts available in the loop.
1093 *
1094 * Calls the {@see 'loop_end'} action when the loop is complete.
1095 *
1096 * @return bool True if posts are available, false if end of the loop.
1097 */
1098 public function have_results(): bool {
1099 if ( $this->current_result + 1 < $this->result_count ) {
1100 return true;
1101 } elseif ( $this->current_result + 1 === $this->result_count && $this->result_count > 0 ) {
1102 /**
1103 * Fires once the loop has ended.
1104 *
1105 * @param self $query The WP_Query instance (passed by reference).
1106 */
1107 do_action_ref_array( $this->hook_prefix . '/loop_end', [ &$this ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1108 do_action_deprecated( $this->cache_group . '_loop_end', [ &$this ], '1.6.9', $this->hook_prefix . '/loop_end' );
1109 // Do some cleaning up after the loop.
1110 $this->rewind_results();
1111 } elseif ( 0 === $this->result_count ) {
1112 $this->before_loop = false;
1113
1114 /**
1115 * Fires if no results are found in a post query.
1116 *
1117 * @param self $query The WP_Query instance.
1118 */
1119 do_action_ref_array( $this->hook_prefix . '/loop_no_results', [&$this] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1120 do_action_deprecated( $this->cache_group . '_loop_no_results', [ &$this ], '1.7.1', $this->hook_prefix . '/loop_no_results' );
1121 }
1122
1123 $this->in_the_loop = false;
1124
1125 return false;
1126 }
1127
1128 /**
1129 * Rewinds the posts and resets post index.
1130 */
1131 public function rewind_results() {
1132 $this->current_result = - 1;
1133 $this->result = null;
1134 if ( $this->result_count > 0 ) {
1135 $this->result = $this->results[0];
1136 }
1137 }
1138
1139 final protected function get_limit_sql( array $args = [], int $page = 1 ): string {
1140 if ( empty( $args['nopaging'] ) ) {
1141 $page = max( 1, absint( $page ) );
1142
1143 // If 'offset' is provided, it takes precedence over 'paged'.
1144 if ( isset( $args['offset'] ) && is_numeric( $args['offset'] ) ) {
1145 $args['offset'] = absint( $args['offset'] );
1146 $pgstrt = $args['offset'] . ', ';
1147 } else {
1148 $pgstrt = absint( ( $page - 1 ) * $args['per_page'] ) . ', ';
1149 }
1150
1151 return 'LIMIT ' . $pgstrt . $args['per_page'];
1152 }
1153
1154 return '';
1155 }
1156
1157 final protected function get_orderby_sql( array $args = [] ): string {
1158 if ( ! empty( $args['orderby'] ) && 'none' !== $args['orderby'] ) {
1159 if ( in_array( strtoupper( $args['order'] ), [ 'ASC', 'DESC' ], true ) ) {
1160 $args['order'] = strtoupper( $args['order'] );
1161 } else {
1162 $args['order'] = 'DESC';
1163 }
1164
1165 $orderby = urldecode( $args['orderby'] );
1166
1167 if ( str_contains( $orderby, '.' ) ) {
1168 $parts = explode( '.', $orderby, 2 );
1169 $orderby = sprintf( '%s`%s`', $parts[0], $parts[1] );
1170 } else {
1171 $orderby = sprintf( '`%s`', $args['orderby'] );
1172 }
1173
1174 $orderby = wp_slash( $orderby );
1175
1176 return $orderby . ' ' . $args['order'];
1177 }
1178
1179 return '';
1180 }
1181
1182 /**
1183 * @throws StoreEngineInvalidArgumentException
1184 */
1185 protected function generate_conditions( $conditions, &$params = [] ): string {
1186 global $wpdb;
1187
1188 if ( ! is_array( $conditions ) ) {
1189 throw new StoreEngineInvalidArgumentException(
1190 sprintf( 'Where args (conditions) must be an array %s given.', esc_html( gettype( $conditions ) ) ),
1191 'invalid-where-args',
1192 [
1193 'conditions' => $conditions, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1194 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1195 ]
1196 );
1197 }
1198
1199 // Allow single where.
1200 if ( isset( $conditions['key'], $conditions['value'] ) ) {
1201 $conditions = [
1202 'relation' => $conditions['relation'] ?? 'AND',
1203 [
1204 'key' => $conditions['key'],
1205 'value' => $conditions['value'],
1206 'compare' => $conditions['compare'] ?? '=',
1207 'type' => $conditions['type'] ?? null,
1208 ],
1209 ];
1210 }
1211
1212 // Get relation if it's named
1213 $relation = 'AND';
1214
1215 if ( isset( $conditions['relation'] ) ) {
1216 $relation = strtoupper( $conditions['relation'] );
1217 unset( $conditions['relation'] );
1218 }
1219
1220 $queryParts = [];
1221
1222 foreach ( $conditions as $condition ) {
1223 if ( isset( $condition['key'] ) ) {
1224 // Normalize
1225 $key = $this->table . '.' . $condition['key'];
1226 $value = $this->normalize_value( $condition['value'] ?? '' );
1227 $compare = isset( $condition['compare'] ) ? strtoupper( $condition['compare'] ) : '=';
1228 $type = isset( $condition['type'] ) ? strtoupper( $condition['type'] ) : null;
1229
1230 // Cast key if needed
1231 if ( $type ) {
1232 if ( 'NUMERIC' === $type ) {
1233 $type = 'SIGNED';
1234 }
1235 if ( 'STRING' === $type ) {
1236 $type = 'CHAR';
1237 }
1238
1239 $allowedTypes = [ 'BINARY', 'CHAR', 'DATE', 'DATETIME', 'DECIMAL', 'SIGNED', 'UNSIGNED', 'TIME' ];
1240
1241 if ( ! in_array( $type, $allowedTypes, true ) ) {
1242 throw new StoreEngineInvalidArgumentException(
1243 'Unsupported cast type (' . esc_html( $type ) . ') in condition.',
1244 'unsupported-cast-type',
1245 [
1246 'condition' => $condition, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1247 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1248 ]
1249 );
1250 }
1251
1252 $key = "CAST($key AS $type)";
1253 }
1254
1255 switch ( $compare ) {
1256 case 'IN':
1257 case 'NOT IN':
1258 if ( ! is_array( $value ) || empty( $value ) ) {
1259 throw new StoreEngineInvalidArgumentException(
1260 'Comparing with `' . esc_html( $compare ) . '` needs a non-empty array as value, ' . esc_html( gettype( $value ) ) . ' given.',
1261 'unsupported-value',
1262 [
1263 'condition' => $condition, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1264 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1265 ]
1266 );
1267 }
1268
1269 $placeholders = implode( ', ', array_fill( 0, count( $value ), is_numeric( current( $value ) ) ? '%d' : '%s' ) );
1270 $queryParts[] = "$key $compare ($placeholders)";
1271
1272 foreach ( $value as $val ) {
1273 $params[] = $val;
1274 }
1275 break;
1276
1277 case 'BETWEEN':
1278 case 'NOT BETWEEN':
1279 if ( ! is_array( $value ) || count( $value ) !== 2 ) {
1280 if ( ! is_array( $value ) ) {
1281 $type = gettype( $value );
1282 } else {
1283 $count = count( $value );
1284 $type = $count . ( 1 === $count ? ' value' : ' values' );
1285 }
1286
1287 throw new StoreEngineInvalidArgumentException(
1288 'Comparing with `' . esc_html( $compare ) . '` requires an array of exactly two values, ' . esc_html( $type ) . ' given.',
1289 'unsupported-value',
1290 [
1291 'condition' => $condition, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1292 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1293 ]
1294 );
1295 }
1296
1297 $queryParts[] = "$key $compare %s AND %s";
1298 $params[] = $value[0];
1299 $params[] = $value[1];
1300 break;
1301
1302 case 'LIKE':
1303 case 'NOT LIKE':
1304 $queryParts[] = "$key $compare %s";
1305 $params[] = implode( '%', array_map( fn( $v ) => $v ? $wpdb->esc_like( $v ) : '', explode( '%', $value ) ) );
1306 break;
1307
1308 // case 'IS NULL':
1309 // case 'IS NOT NULL':
1310 // $queryParts[] = "$key $compare";
1311 // break;
1312
1313 case 'IS NOT NULL':
1314 case 'EXISTS':
1315 $queryParts[] = "$key IS NOT NULL";
1316 break;
1317
1318 case 'IS NULL':
1319 case 'NOT EXISTS':
1320 $queryParts[] = "$key IS NULL";
1321 break;
1322
1323 default:
1324 if ( ! in_array( $compare, [ '=', '!=', '<>', '<', '>', '<=', '>=', '<=>' ], true ) ) {
1325 throw new StoreEngineInvalidArgumentException(
1326 'Invalid compare (' . esc_html( $compare ) . ') in where args (condition) format.',
1327 'invalid-where-args-format',
1328 [
1329 'condition' => $condition, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1330 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1331 ]
1332 );
1333 }
1334
1335 $placeholder = is_numeric( $value ) ? '%d' : '%s';
1336 $queryParts[] = "$key $compare $placeholder";
1337 $params[] = $value;
1338 break;
1339 }
1340 } elseif ( is_array( $condition ) && ! empty( $condition ) ) {
1341 $sub_query = $this->generate_conditions( $condition, $params );
1342 $queryParts[] = "($sub_query)";
1343 } else {
1344 throw new StoreEngineInvalidArgumentException(
1345 'Invalid where (condition) args format',
1346 'invalid-where-args-format',
1347 [
1348 'condition' => $condition, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1349 'collection' => get_class( $this ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1350 ]
1351 );
1352 }
1353 }
1354
1355 return implode( " $relation ", $queryParts );
1356 }
1357
1358 protected function normalize_value( $value ) {
1359 if ( ! is_string( $value ) ) {
1360 // Early bail if not string.
1361 return $value;
1362 }
1363
1364 // Full datetime: Y-m-d H:i:s
1365 $datetime_keywords = [ 'now', 'getdate', 'current_timestamp', 'localtime', 'sysdate' ];
1366 // Date only: Y-m-d
1367 $date_keywords = [ 'curdate', 'current_date' ];
1368 // Time only: H:i:s
1369 $time_keywords = [ 'curtime', 'current_time' ];
1370 // Normalize keyword
1371 $keyword = strtolower( rtrim( trim( $value ), '()' ) );
1372
1373 if ( in_array( $keyword, $datetime_keywords, true ) ) {
1374 $value = current_time( 'mysql', 1 );
1375 } elseif ( in_array( $keyword, $date_keywords, true ) ) {
1376 $value = substr( current_time( 'mysql', 1 ), 0, 10 );
1377 } elseif ( in_array( $keyword, $time_keywords, true ) ) {
1378 $value = current_time( 'H:i:s', 1 );
1379 }
1380
1381 return $value;
1382 }
1383
1384 /**
1385 * Generates cache key.
1386 *
1387 * @param array $args Query arguments.
1388 * @param string $sql SQL statement.
1389 *
1390 * @return string Cache key.
1391 * @global wpdb $wpdb WordPress database abstraction object.
1392 */
1393 protected function generate_cache_key( array $args, string $sql ): string {
1394 global $wpdb;
1395
1396 unset(
1397 $args['fields'],
1398 $args['suppress_filters']
1399 );
1400
1401 if ( isset( $args['post_status'] ) ) {
1402 $args['post_status'] = (array) $args['post_status'];
1403 // Sort post status to ensure same cache key generation.
1404 sort( $args['post_status'] );
1405 }
1406
1407 // Add a default orderby value of date to ensure same cache key generation.
1408 if ( ! isset( $args['orderby'] ) ) {
1409 $args['orderby'] = $this->primary_key;
1410 }
1411
1412 $placeholder = $wpdb->placeholder_escape();
1413 array_walk_recursive(
1414 $args,
1415 /*
1416 * Replace wpdb placeholders with the string used in the database
1417 * query to avoid unreachable cache keys. This is necessary because
1418 * the placeholder is randomly generated in each request.
1419 *
1420 * $value is passed by reference to allow it to be modified.
1421 * array_walk_recursive() does not return an array.
1422 */
1423 static function ( &$value ) use ( $wpdb, $placeholder ) {
1424 if ( is_string( $value ) && str_contains( $value, $placeholder ) ) {
1425 $value = $wpdb->remove_placeholder_escape( $value );
1426 }
1427 }
1428 );
1429
1430 ksort( $args );
1431
1432 // Replace wpdb placeholder in the SQL statement used by the cache key.
1433 $sql = $wpdb->remove_placeholder_escape( $sql );
1434 $key = md5( maybe_serialize( $args ) . $sql );
1435
1436 $last_changed = wp_cache_get_last_changed( $this->cache_group );
1437
1438 return get_class( $this ) . ":$key:$last_changed";
1439 }
1440
1441 /**
1442 * Adds any items from the given IDs to the cache that do not already exist in cache.
1443 *
1444 * Objects like Order that have multiple joins should redeclare this method, so complex like order (that includes multiple tables)
1445 * gets cached properly.
1446 *
1447 * @param int[] $ids ID list.
1448 *
1449 * @see update_post_cache()
1450 * @see update_postmeta_cache()
1451 * @see update_object_term_cache()
1452 *
1453 * @global wpdb $wpdb WordPress database abstraction object.
1454 *
1455 * @see _prime_post_caches()
1456 */
1457 protected function _prime_item_caches( array $ids ) {
1458 // @TODO Prime cache bug that can cache broken data
1459 // As entity cache data after reading which might includes metadata or data from
1460 // other table.
1461 return;
1462
1463 global $wpdb;
1464
1465 $non_cached_ids = _get_non_cached_ids( $ids, $this->cache_group );
1466
1467 if ( ! empty( $non_cached_ids ) ) {
1468 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
1469 $fresh_items = $wpdb->get_results( sprintf( "SELECT $this->table.* FROM $this->table WHERE $this->primary_key IN (%s)", implode( ',', $non_cached_ids ) ) );
1470 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
1471
1472 if ( $fresh_items ) {
1473 $this->update_items_cache( $fresh_items );
1474 }
1475 }
1476 }
1477
1478 /**
1479 * Updates posts in cache.
1480 *
1481 * @param object[] $items Array of item objects (passed by reference).
1482 *
1483 * @see update_post_cache()
1484 */
1485 protected function update_items_cache( array &$items ) {
1486 if ( ! $items ) {
1487 return;
1488 }
1489
1490 $data = [];
1491 foreach ( $items as $item ) {
1492 if ( empty( $item->filter ) || 'raw' !== $item->filter ) {
1493 $item->filter = 'row';
1494 $item = sanitize_post( $item, 'raw' );
1495 }
1496
1497 $data[ $item->{$this->primary_key} ] = $item;
1498 }
1499
1500 wp_cache_add_multiple( $data, $this->cache_group );
1501 }
1502
1503 /**
1504 * Prime the cache containing the parent ID of various post objects.
1505 *
1506 * @param int[] $ids ID list.
1507 *
1508 * @global wpdb $wpdb WordPress database abstraction object.
1509 *
1510 * @see _prime_post_parent_id_caches()
1511 */
1512 protected function _prime_result_parent_id_caches( array $ids ) {
1513 global $wpdb;
1514
1515 $ids = array_filter( $ids, '_validate_cache_id' );
1516 $ids = array_unique( array_map( 'intval', $ids ), SORT_NUMERIC );
1517
1518 if ( empty( $ids ) ) {
1519 return;
1520 }
1521
1522 $cache_keys = [];
1523 foreach ( $ids as $id ) {
1524 $cache_keys[ $id ] = $this->object_type . '_parent:' . $id;
1525 }
1526
1527 $cached_data = wp_cache_get_multiple( array_values( $cache_keys ), $this->cache_group );
1528
1529 $non_cached_ids = [];
1530 foreach ( $cache_keys as $id => $cache_key ) {
1531 if ( false === $cached_data[ $cache_key ] ) {
1532 $non_cached_ids[] = $id;
1533 }
1534 }
1535
1536 if ( ! empty( $non_cached_ids ) ) {
1537 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
1538 $fresh_items = $wpdb->get_results( sprintf( "SELECT $this->table.$this->primary_key, $this->table.$this->parent_key FROM $this->table WHERE $this->primary_key IN (%s)", implode( ',', $non_cached_ids ) ) );
1539 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- query prepared and cached.
1540
1541 if ( $fresh_items ) {
1542 $item_parent_data = [];
1543 foreach ( $fresh_items as $fresh_item ) {
1544 $item_parent_data[ $this->object_type . '_parent:' . $fresh_item->{$this->primary_key} ] = (int) $fresh_item->{$this->parent_key};
1545 }
1546
1547 wp_cache_add_multiple( $item_parent_data, $this->cache_group );
1548 }
1549 }
1550 }
1551
1552 public function getIterator(): ArrayIterator {
1553 return new ArrayIterator( $this->get_results() );
1554 }
1555
1556 public function count(): int {
1557 return $this->result_count;
1558 }
1559
1560 public function get_found_results(): int {
1561 return $this->found_results;
1562 }
1563
1564 public function get_max_num_pages(): int {
1565 return $this->max_num_pages;
1566 }
1567
1568 public function get_per_page(): ?int {
1569 return $this->per_page;
1570 }
1571
1572 public function get_no_paging(): ?bool {
1573 return $this->nopaging;
1574 }
1575
1576 /**
1577 * Get the pagination.
1578 *
1579 * @param string $label
1580 * @param string|string[] $args
1581 *
1582 * @return string
1583 * @see _navigation_markup()
1584 * @see paginate_links()
1585 *
1586 * @see get_the_posts_pagination()
1587 */
1588 public function paginate_links( string $label, $args = [] ): string {
1589 $navigation = '';
1590
1591 if ( ! $this->get_max_num_pages() ) {
1592 return $navigation;
1593 }
1594
1595 // Don't print empty markup if there's only one page.
1596 if ( $this->get_max_num_pages() > 1 ) {
1597 // Make sure the nav element has an aria-label attribute: fallback to the screen reader text.
1598 if ( ! empty( $args['screen_reader_text'] ) && empty( $args['aria_label'] ) ) {
1599 $args['aria_label'] = $args['screen_reader_text'];
1600 }
1601
1602 $args = wp_parse_args(
1603 $args,
1604 [
1605 'base' => str_replace( PHP_INT_MAX, '%#%', esc_url( get_pagenum_link( PHP_INT_MAX ) ) ),
1606 'format' => '?paged=%#%',
1607 'current' => max( 1, $paged ?? get_query_var( 'paged' ) ),
1608 'total' => $this->get_max_num_pages(),
1609 'prev_text' => sprintf(
1610 '<span class="screen-reader-text">%1$s</span> <i class="storeengine-icon storeengine-icon--%2$s" aria-hidden="true"></i>',
1611 _x( 'Previous', 'Pagination previous set of items', 'storeengine' ),
1612 is_rtl() ? 'arrow-right' : 'arrow-left'
1613 ),
1614 'next_text' => sprintf(
1615 '<i class="storeengine-icon storeengine-icon--%2$s" aria-hidden="true"></i> <span class="screen-reader-text">%1$s</span>',
1616 _x( 'Next', 'Pagination next set of items', 'storeengine' ),
1617 is_rtl() ? 'arrow-left' : 'arrow-right'
1618 ),
1619 'screen_reader_text' => sprintf(
1620 /* translators: Hidden accessibility text. %s. Pagination label. */
1621 _x( '%s pagination', 'Pagination nav header', 'storeengine' ),
1622 $label
1623 ),
1624 'aria_label' => sprintf(
1625 /* translators: %s. Pagination label. */
1626 _x( '%s pagination', 'Pagination nave aria-label', 'storeengine' ),
1627 $label
1628 ),
1629 'class' => 'pagination',
1630 'show_total' => true,
1631 ]
1632 );
1633
1634 /**
1635 * Filters the arguments for posts pagination links.
1636 *
1637 * @param array $args {
1638 * Optional. Default pagination arguments, see paginate_links().
1639 *
1640 * @type string $screen_reader_text Screen reader text for navigation element.
1641 * Default 'Posts navigation'.
1642 * @type string $aria_label ARIA label text for the nav element. Default 'Posts'.
1643 * @type string $class Custom class for the nav element. Default 'pagination'.
1644 * }
1645 */
1646 $args = apply_filters( "{$this->hook_prefix}/the_pagination_args", $args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1647
1648 // Make sure we get a string back. Plain is the next best thing.
1649 if ( isset( $args['type'] ) && 'array' === $args['type'] ) {
1650 $args['type'] = 'plain';
1651 }
1652
1653 // Set up paginated links.
1654 $links = paginate_links( $args );
1655
1656 // Wraps passed links in navigational markup.
1657 if ( $links ) {
1658 $template = '
1659 <nav class="%1$s" role="navigation" aria-label="%4$s">
1660 <h2 class="screen-reader-text">%2$s</h2>
1661 <div class="nav-links">%3$s</div>
1662 </nav>';
1663
1664 /**
1665 * Filters the navigation markup template.
1666 *
1667 * Note: The filtered template HTML must contain specifiers for the navigation
1668 * class (%1$s), the screen-reader-text value (%2$s), placement of the navigation
1669 * links (%3$s), and ARIA label text if screen-reader-text does not fit that (%4$s):
1670 *
1671 * <nav class="navigation %1$s" aria-label="%4$s">
1672 * <h2 class="screen-reader-text">%2$s</h2>
1673 * <div class="nav-links">%3$s</div>
1674 * </nav>
1675 *
1676 * @param string $template The default template.
1677 * @param string $css_class The class passed by the calling function.
1678 */
1679 $template = apply_filters( "{$this->hook_prefix}/navigation_markup_template", $template, $args['class'] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1680 $classes = trim( 'navigation storeengine-pagination ' . sanitize_html_class( $args['class'] ) );
1681 $navigation = sprintf( $template, $classes, esc_html( $args['screen_reader_text'] ), $links, esc_attr( $args['aria_label'] ) );
1682 }
1683
1684 if ( $args['show_total'] ) {
1685 $navigation = sprintf(
1686 /* translators: 1: Starting number of items on the current page, 2: Ending number of items, 3: Total number of items. */
1687 '<div class="displaying-num">' . esc_html_x( 'Displaying %1$s&#8211;%2$s of %3$s', 'Displaying total item before pagination. E.g. Displaying 10 of 30', 'storeengine' ) . '</div>',
1688 number_format_i18n( ( $this->page - 1 ) * $this->per_page + 1 ),
1689 number_format_i18n( min( $this->page * $this->per_page, $this->get_found_results() ) ),
1690 number_format_i18n( $this->get_found_results() )
1691 )
1692 . PHP_EOL
1693 . $navigation;
1694
1695 $navigation = '<div class="storeengine-width-full storeengine-flex storeengine-flex-align-center storeengine-flex-justify-between">' . $navigation . '</div>';
1696 }
1697 }
1698
1699 return $navigation;
1700 }
1701
1702 /**
1703 * Render/Print pagination.
1704 *
1705 * @param string $label
1706 * @param string|string[] $args
1707 *
1708 * @return void
1709 */
1710 public function render_pagination( string $label, $args = [] ): void {
1711 echo wp_kses_post( $this->paginate_links( $label, $args ) );
1712 }
1713
1714 /**
1715 * @param string $label
1716 * @param string|string[] $args
1717 *
1718 * @return string
1719 *
1720 * @deprecated
1721 */
1722 public function get_the_pagination( string $label, $args = [] ): string {
1723 return $this->paginate_links( $label, $args );
1724 }
1725
1726 /**
1727 * Render/Print pagination.
1728 *
1729 * @param string $label
1730 * @param string|string[] $args
1731 *
1732 * @return void
1733 *
1734 * @deprecated
1735 */
1736 public function the_posts_pagination( string $label, $args = [] ): void {
1737 $this->render_pagination( $label, $args );
1738 }
1739 }
1740