1<?php
2/**
3 * Class for generating SQL clauses that filter a primary query according to date.
4 *
5 * WP_Date_Query is a helper that allows primary query classes, such as WP_Query, to filter
6 * their results by date columns, by generating `WHERE` subclauses to be attached to the
7 * primary SQL query string.
8 *
9 * Attempting to filter by an invalid date value (eg month=13) will generate SQL that will
10 * return no results. In these cases, a _doing_it_wrong() error notice is also thrown.
11 * See WP_Date_Query::validate_date_values().
12 *
13 * @link https://developer.wordpress.org/reference/classes/wp_query/
14 *
15 * @since 3.7.0
16 */
17#[AllowDynamicProperties]
18class WP_Date_Query {
19 /**
20 * Array of date queries.
21 *
22 * See WP_Date_Query::__construct() for information on date query arguments.
23 *
24 * @since 3.7.0
25 * @var array
26 */
27 public $queries = array();
28
29 /**
30 * The default relation between top-level queries. Can be either 'AND' or 'OR'.
31 *
32 * @since 3.7.0
33 * @var string
34 */
35 public $relation = 'AND';
36
37 /**
38 * The column to query against. Can be changed via the query arguments.
39 *
40 * @since 3.7.0
41 * @var string
42 */
43 public $column = 'post_date';
44
45 /**
46 * The value comparison operator. Can be changed via the query arguments.
47 *
48 * @since 3.7.0
49 * @var string
50 */
51 public $compare = '=';
52
53 /**
54 * Supported time-related parameter keys.
55 *
56 * @since 4.1.0
57 * @var string[]
58 */
59 public $time_keys = array( 'after', 'before', 'year', 'month', 'monthnum', 'week', 'w', 'dayofyear', 'day', 'dayofweek', 'dayofweek_iso', 'hour', 'minute', 'second' );
60
61 /**
62 * Constructor.
63 *
64 * Time-related parameters that normally require integer values ('year', 'month', 'week', 'dayofyear', 'day',
65 * 'dayofweek', 'dayofweek_iso', 'hour', 'minute', 'second') accept arrays of integers for some values of
66 * 'compare'. When 'compare' is 'IN' or 'NOT IN', arrays are accepted; when 'compare' is 'BETWEEN' or 'NOT
67 * BETWEEN', arrays of two valid values are required. See individual argument descriptions for accepted values.
68 *
69 * @since 3.7.0
70 * @since 4.0.0 The $inclusive logic was updated to include all times within the date range.
71 * @since 4.1.0 Introduced 'dayofweek_iso' time type parameter.
72 *
73 * @param array $date_query {
74 * Array of date query clauses.
75 *
76 * @type array ...$0 {
77 * @type string $column Optional. The column to query against. If undefined, inherits the value of
78 * the `$default_column` parameter. See WP_Date_Query::validate_column() and
79 * the {@see 'date_query_valid_columns'} filter for the list of accepted values.
80 * Default 'post_date'.
81 * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', '<', '<=',
82 * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Default '='.
83 * @type string $relation Optional. The boolean relationship between the date queries. Accepts 'OR' or 'AND'.
84 * Default 'OR'.
85 * @type array ...$0 {
86 * Optional. An array of first-order clause parameters, or another fully-formed date query.
87 *
88 * @type string|array $before {
89 * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string,
90 * or array of 'year', 'month', 'day' values.
91 *
92 * @type string $year The four-digit year. Default empty. Accepts any four-digit year.
93 * @type string $month Optional when passing array.The month of the year.
94 * Default (string:empty)|(array:1). Accepts numbers 1-12.
95 * @type string $day Optional when passing array.The day of the month.
96 * Default (string:empty)|(array:1). Accepts numbers 1-31.
97 * }
98 * @type string|array $after {
99 * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string,
100 * or array of 'year', 'month', 'day' values.
101 *
102 * @type string $year The four-digit year. Accepts any four-digit year. Default empty.
103 * @type string $month Optional when passing array. The month of the year. Accepts numbers 1-12.
104 * Default (string:empty)|(array:12).
105 * @type string $day Optional when passing array.The day of the month. Accepts numbers 1-31.
106 * Default (string:empty)|(array:last day of month).
107 * }
108 * @type string $column Optional. Used to add a clause comparing a column other than
109 * the column specified in the top-level `$column` parameter.
110 * See WP_Date_Query::validate_column() and
111 * the {@see 'date_query_valid_columns'} filter for the list
112 * of accepted values. Default is the value of top-level `$column`.
113 * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=',
114 * '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. 'IN',
115 * 'NOT IN', 'BETWEEN', and 'NOT BETWEEN'. Comparisons support
116 * arrays in some time-related parameters. Default '='.
117 * @type bool $inclusive Optional. Include results from dates specified in 'before' or
118 * 'after'. Default false.
119 * @type int|int[] $year Optional. The four-digit year number. Accepts any four-digit year
120 * or an array of years if `$compare` supports it. Default empty.
121 * @type int|int[] $month Optional. The two-digit month number. Accepts numbers 1-12 or an
122 * array of valid numbers if `$compare` supports it. Default empty.
123 * @type int|int[] $week Optional. The week number of the year. Accepts numbers 0-53 or an
124 * array of valid numbers if `$compare` supports it. Default empty.
125 * @type int|int[] $dayofyear Optional. The day number of the year. Accepts numbers 1-366 or an
126 * array of valid numbers if `$compare` supports it.
127 * @type int|int[] $day Optional. The day of the month. Accepts numbers 1-31 or an array
128 * of valid numbers if `$compare` supports it. Default empty.
129 * @type int|int[] $dayofweek Optional. The day number of the week. Accepts numbers 1-7 (1 is
130 * Sunday) or an array of valid numbers if `$compare` supports it.
131 * Default empty.
132 * @type int|int[] $dayofweek_iso Optional. The day number of the week (ISO). Accepts numbers 1-7
133 * (1 is Monday) or an array of valid numbers if `$compare` supports it.
134 * Default empty.
135 * @type int|int[] $hour Optional. The hour of the day. Accepts numbers 0-23 or an array
136 * of valid numbers if `$compare` supports it. Default empty.
137 * @type int|int[] $minute Optional. The minute of the hour. Accepts numbers 0-59 or an array
138 * of valid numbers if `$compare` supports it. Default empty.
139 * @type int|int[] $second Optional. The second of the minute. Accepts numbers 0-59 or an
140 * array of valid numbers if `$compare` supports it. Default empty.
141 * }
142 * }
143 * }
144 * @param string $default_column Optional. Default column to query against. See WP_Date_Query::validate_column()
145 * and the {@see 'date_query_valid_columns'} filter for the list of accepted values.
146 * Default 'post_date'.
147 */
148 public function __construct( $date_query, $default_column = 'post_date' ) {
149 if ( empty( $date_query ) || ! is_array( $date_query ) ) {
150 return;
151 }
152
153 if ( isset( $date_query['relation'] ) ) {
154 $this->relation = $this->sanitize_relation( $date_query['relation'] );
155 } else {
156 $this->relation = 'AND';
157 }
158
159 // Support for passing time-based keys in the top level of the $date_query array.
160 if ( ! isset( $date_query[0] ) ) {
161 $date_query = array( $date_query );
162 }
163
164 if ( ! empty( $date_query['column'] ) ) {
165 $date_query['column'] = esc_sql( $date_query['column'] );
166 } else {
167 $date_query['column'] = esc_sql( $default_column );
168 }
169
170 $this->column = $this->validate_column( $this->column );
171
172 $this->compare = $this->get_compare( $date_query );
173
174 $this->queries = $this->sanitize_query( $date_query );
175 }
176
177 /**
178 * Recursive-friendly query sanitizer.
179 *
180 * Ensures that each query-level clause has a 'relation' key, and that
181 * each first-order clause contains all the necessary keys from `$defaults`.
182 *
183 * @since 4.1.0
184 *
185 * @param array $queries
186 * @param array $parent_query
187 * @return array Sanitized queries.
188 */
189 public function sanitize_query( $queries, $parent_query = null ) {
190 $cleaned_query = array();
191
192 $defaults = array(
193 'column' => 'post_date',
194 'compare' => '=',
195 'relation' => 'AND',
196 );
197
198 // Numeric keys should always have array values.
199 foreach ( $queries as $qkey => $qvalue ) {
200 if ( is_numeric( $qkey ) && ! is_array( $qvalue ) ) {
201 unset( $queries[ $qkey ] );
202 }
203 }
204
205 // Each query should have a value for each default key. Inherit from the parent when possible.
206 foreach ( $defaults as $dkey => $dvalue ) {
207 if ( isset( $queries[ $dkey ] ) ) {
208 continue;
209 }
210
211 if ( isset( $parent_query[ $dkey ] ) ) {
212 $queries[ $dkey ] = $parent_query[ $dkey ];
213 } else {
214 $queries[ $dkey ] = $dvalue;
215 }
216 }
217
218 // Validate the dates passed in the query.
219 if ( $this->is_first_order_clause( $queries ) ) {
220 $this->validate_date_values( $queries );
221 }
222
223 // Sanitize the relation parameter.
224 $queries['relation'] = $this->sanitize_relation( $queries['relation'] );
225
226 foreach ( $queries as $key => $q ) {
227 if ( ! is_array( $q ) || in_array( $key, $this->time_keys, true ) ) {
228 // This is a first-order query. Trust the values and sanitize when building SQL.
229 $cleaned_query[ $key ] = $q;
230 } else {
231 // Any array without a time key is another query, so we recurse.
232 $cleaned_query[] = $this->sanitize_query( $q, $queries );
233 }
234 }
235
236 return $cleaned_query;
237 }
238
239 /**
240 * Determines whether this is a first-order clause.
241 *
242 * Checks to see if the current clause has any time-related keys.
243 * If so, it's first-order.
244 *
245 * @since 4.1.0
246 *
247 * @param array $query Query clause.
248 * @return bool True if this is a first-order clause.
249 */
250 protected function is_first_order_clause( $query ) {
251 $time_keys = array_intersect( $this->time_keys, array_keys( $query ) );
252 return ! empty( $time_keys );
253 }
254
255 /**
256 * Determines and validates what comparison operator to use.
257 *
258 * @since 3.7.0
259 *
260 * @param array $query A date query or a date subquery.
261 * @return string The comparison operator.
262 */
263 public function get_compare( $query ) {
264 if ( ! empty( $query['compare'] )
265 && in_array( $query['compare'], array( '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true )
266 ) {
267 return strtoupper( $query['compare'] );
268 }
269
270 return $this->compare;
271 }
272
273 /**
274 * Validates the given date_query values and triggers errors if something is not valid.
275 *
276 * Note that date queries with invalid date ranges are allowed to
277 * continue (though of course no items will be found for impossible dates).
278 * This method only generates debug notices for these cases.
279 *
280 * @since 4.1.0
281 *
282 * @param array $date_query The date_query array.
283 * @return bool True if all values in the query are valid, false if one or more fail.
284 */
285 public function validate_date_values( $date_query = array() ) {
286 if ( empty( $date_query ) ) {
287 return false;
288 }
289
290 $valid = true;
291
292 /*
293 * Validate 'before' and 'after' up front, then let the
294 * validation routine continue to be sure that all invalid
295 * values generate errors too.
296 */
297 if ( array_key_exists( 'before', $date_query ) && is_array( $date_query['before'] ) ) {
298 $valid = $this->validate_date_values( $date_query['before'] );
299 }
300
301 if ( array_key_exists( 'after', $date_query ) && is_array( $date_query['after'] ) ) {
302 $valid = $this->validate_date_values( $date_query['after'] );
303 }
304
305 // Array containing all min-max checks.
306 $min_max_checks = array();
307
308 // Days per year.
309 if ( array_key_exists( 'year', $date_query ) ) {
310 /*
311 * If a year exists in the date query, we can use it to get the days.
312 * If multiple years are provided (as in a BETWEEN), use the first one.
313 */
314 if ( is_array( $date_query['year'] ) ) {
315 $_year = reset( $date_query['year'] );
316 } else {
317 $_year = $date_query['year'];
318 }
319
320 $max_days_of_year = (int) gmdate( 'z', mktime( 0, 0, 0, 12, 31, $_year ) ) + 1;
321 } else {
322 // Otherwise we use the max of 366 (leap-year).
323 $max_days_of_year = 366;
324 }
325
326 $min_max_checks['dayofyear'] = array(
327 'min' => 1,
328 'max' => $max_days_of_year,
329 );
330
331 // Days per week.
332 $min_max_checks['dayofweek'] = array(
333 'min' => 1,
334 'max' => 7,
335 );
336
337 // Days per week.
338 $min_max_checks['dayofweek_iso'] = array(
339 'min' => 1,
340 'max' => 7,
341 );
342
343 // Months per year.
344 $min_max_checks['month'] = array(
345 'min' => 1,
346 'max' => 12,
347 );
348
349 // Weeks per year.
350 if ( isset( $_year ) ) {
351 /*
352 * If we have a specific year, use it to calculate number of weeks.
353 * Note: the number of weeks in a year is the date in which Dec 28 appears.
354 */
355 $week_count = gmdate( 'W', mktime( 0, 0, 0, 12, 28, $_year ) );
356
357 } else {
358 // Otherwise set the week-count to a maximum of 53.
359 $week_count = 53;
360 }
361
362 $min_max_checks['week'] = array(
363 'min' => 1,
364 'max' => $week_count,
365 );
366
367 // Days per month.
368 $min_max_checks['day'] = array(
369 'min' => 1,
370 'max' => 31,
371 );
372
373 // Hours per day.
374 $min_max_checks['hour'] = array(
375 'min' => 0,
376 'max' => 23,
377 );
378
379 // Minutes per hour.
380 $min_max_checks['minute'] = array(
381 'min' => 0,
382 'max' => 59,
383 );
384
385 // Seconds per minute.
386 $min_max_checks['second'] = array(
387 'min' => 0,
388 'max' => 59,
389 );
390
391 // Concatenate and throw a notice for each invalid value.
392 foreach ( $min_max_checks as $key => $check ) {
393 if ( ! array_key_exists( $key, $date_query ) ) {
394 continue;
395 }
396
397 // Throw a notice for each failing value.
398 foreach ( (array) $date_query[ $key ] as $_value ) {
399 $is_between = $_value >= $check['min'] && $_value <= $check['max'];
400
401 if ( ! is_numeric( $_value ) || ! $is_between ) {
402 $error = sprintf(
403 /* translators: Date query invalid date message. 1: Invalid value, 2: Type of value, 3: Minimum valid value, 4: Maximum valid value. */
404 __( 'Invalid value %1$s for %2$s. Expected value should be between %3$s and %4$s.' ),
405 '<code>' . esc_html( $_value ) . '</code>',
406 '<code>' . esc_html( $key ) . '</code>',
407 '<code>' . esc_html( $check['min'] ) . '</code>',
408 '<code>' . esc_html( $check['max'] ) . '</code>'
409 );
410
411 _doing_it_wrong( __CLASS__, $error, '4.1.0' );
412
413 $valid = false;
414 }
415 }
416 }
417
418 // If we already have invalid date messages, don't bother running through checkdate().
419 if ( ! $valid ) {
420 return $valid;
421 }
422
423 $day_month_year_error_msg = '';
424
425 $day_exists = array_key_exists( 'day', $date_query ) && is_numeric( $date_query['day'] );
426 $month_exists = array_key_exists( 'month', $date_query ) && is_numeric( $date_query['month'] );
427 $year_exists = array_key_exists( 'year', $date_query ) && is_numeric( $date_query['year'] );
428
429 if ( $day_exists && $month_exists && $year_exists ) {
430 // 1. Checking day, month, year combination.
431 if ( ! wp_checkdate( $date_query['month'], $date_query['day'], $date_query['year'], sprintf( '%s-%s-%s', $date_query['year'], $date_query['month'], $date_query['day'] ) ) ) {
432 $day_month_year_error_msg = sprintf(
433 /* translators: 1: Year, 2: Month, 3: Day of month. */
434 __( 'The following values do not describe a valid date: year %1$s, month %2$s, day %3$s.' ),
435 '<code>' . esc_html( $date_query['year'] ) . '</code>',
436 '<code>' . esc_html( $date_query['month'] ) . '</code>',
437 '<code>' . esc_html( $date_query['day'] ) . '</code>'
438 );
439
440 $valid = false;
441 }
442 } elseif ( $day_exists && $month_exists ) {
443 /*
444 * 2. checking day, month combination
445 * We use 2012 because, as a leap year, it's the most permissive.
446 */
447 if ( ! wp_checkdate( $date_query['month'], $date_query['day'], 2012, sprintf( '2012-%s-%s', $date_query['month'], $date_query['day'] ) ) ) {
448 $day_month_year_error_msg = sprintf(
449 /* translators: 1: Month, 2: Day of month. */
450 __( 'The following values do not describe a valid date: month %1$s, day %2$s.' ),
451 '<code>' . esc_html( $date_query['month'] ) . '</code>',
452 '<code>' . esc_html( $date_query['day'] ) . '</code>'
453 );
454
455 $valid = false;
456 }
457 }
458
459 if ( ! empty( $day_month_year_error_msg ) ) {
460 _doing_it_wrong( __CLASS__, $day_month_year_error_msg, '4.1.0' );
461 }
462
463 return $valid;
464 }
465
466 /**
467 * Validates a column name parameter.
468 *
469 * Column names without a table prefix (like 'post_date') are checked against a list of
470 * allowed and known tables, and then, if found, have a table prefix (such as 'wp_posts.')
471 * prepended. Prefixed column names (such as 'wp_posts.post_date') bypass this allowed
472 * check, and are only sanitized to remove illegal characters.
473 *
474 * @since 3.7.0
475 *
476 * @global wpdb $wpdb WordPress database abstraction object.
477 *
478 * @param string $column The user-supplied column name.
479 * @return string A validated column name value.
480 */
481 public function validate_column( $column ) {
482 global $wpdb;
483
484 $valid_columns = array(
485 'post_date', // Part of $wpdb->posts.
486 'post_date_gmt', // Part of $wpdb->posts.
487 'post_modified', // Part of $wpdb->posts.
488 'post_modified_gmt', // Part of $wpdb->posts.
489 'comment_date', // Part of $wpdb->comments.
490 'comment_date_gmt', // Part of $wpdb->comments.
491 'user_registered', // Part of $wpdb->users.
492 );
493
494 if ( is_multisite() ) {
495 $valid_columns = array_merge(
496 $valid_columns,
497 array(
498 'registered', // Part of $wpdb->blogs.
499 'last_updated', // Part of $wpdb->blogs.
500 )
501 );
502 }
503
504 // Attempt to detect a table prefix.
505 if ( ! str_contains( $column, '.' ) ) {
506 /**
507 * Filters the list of valid date query columns.
508 *
509 * @since 3.7.0
510 * @since 4.1.0 Added 'user_registered' to the default recognized columns.
511 * @since 4.6.0 Added 'registered' and 'last_updated' to the default recognized columns.
512 *
513 * @param string[] $valid_columns An array of valid date query columns. Defaults
514 * are 'post_date', 'post_date_gmt', 'post_modified',
515 * 'post_modified_gmt', 'comment_date', 'comment_date_gmt',
516 * 'user_registered', 'registered', 'last_updated'.
517 */
518 if ( ! in_array( $column, apply_filters( 'date_query_valid_columns', $valid_columns ), true ) ) {
519 $column = 'post_date';
520 }
521
522 $known_columns = array(
523 $wpdb->posts => array(
524 'post_date',
525 'post_date_gmt',
526 'post_modified',
527 'post_modified_gmt',
528 ),
529 $wpdb->comments => array(
530 'comment_date',
531 'comment_date_gmt',
532 ),
533 $wpdb->users => array(
534 'user_registered',
535 ),
536 );
537
538 if ( is_multisite() ) {
539 $known_columns[ $wpdb->blogs ] = array(
540 'registered',
541 'last_updated',
542 );
543 }
544
545 // If it's a known column name, add the appropriate table prefix.
546 foreach ( $known_columns as $table_name => $table_columns ) {
547 if ( in_array( $column, $table_columns, true ) ) {
548 $column = $table_name . '.' . $column;
549 break;
550 }
551 }
552 }
553
554 // Remove unsafe characters.
555 return preg_replace( '/[^a-zA-Z0-9_$\.]/', '', $column );
556 }
557
558 /**
559 * Generates WHERE clause to be appended to a main query.
560 *
561 * @since 3.7.0
562 *
563 * @return string MySQL WHERE clause.
564 */
565 public function get_sql() {
566 $sql = $this->get_sql_clauses();
567
568 $where = $sql['where'];
569
570 /**
571 * Filters the date query WHERE clause.
572 *
573 * @since 3.7.0
574 *
575 * @param string $where WHERE clause of the date query.
576 * @param WP_Date_Query $query The WP_Date_Query instance.
577 */
578 return apply_filters( 'get_date_sql', $where, $this );
579 }
580
581 /**
582 * Generates SQL clauses to be appended to a main query.
583 *
584 * Called by the public WP_Date_Query::get_sql(), this method is abstracted
585 * out to maintain parity with the other Query classes.
586 *
587 * @since 4.1.0
588 *
589 * @return string[] {
590 * Array containing JOIN and WHERE SQL clauses to append to the main query.
591 *
592 * @type string $join SQL fragment to append to the main JOIN clause.
593 * @type string $where SQL fragment to append to the main WHERE clause.
594 * }
595 */
596 protected function get_sql_clauses() {
597 $sql = $this->get_sql_for_query( $this->queries );
598
599 if ( ! empty( $sql['where'] ) ) {
600 $sql['where'] = ' AND ' . $sql['where'];
601 }
602
603 return $sql;
604 }
605
606 /**
607 * Generates SQL clauses for a single query array.
608 *
609 * If nested subqueries are found, this method recurses the tree to
610 * produce the properly nested SQL.
611 *
612 * @since 4.1.0
613 *
614 * @param array $query Query to parse.
615 * @param int $depth Optional. Number of tree levels deep we currently are.
616 * Used to calculate indentation. Default 0.
617 * @return array {
618 * Array containing JOIN and WHERE SQL clauses to append to a single query array.
619 *
620 * @type string $join SQL fragment to append to the main JOIN clause.
621 * @type string $where SQL fragment to append to the main WHERE clause.
622 * }
623 */
624 protected function get_sql_for_query( $query, $depth = 0 ) {
625 $sql_chunks = array(
626 'join' => array(),
627 'where' => array(),
628 );
629
630 $sql = array(
631 'join' => '',
632 'where' => '',
633 );
634
635 $indent = '';
636 for ( $i = 0; $i < $depth; $i++ ) {
637 $indent .= ' ';
638 }
639
640 foreach ( $query as $key => $clause ) {
641 if ( 'relation' === $key ) {
642 $relation = $query['relation'];
643 } elseif ( is_array( $clause ) ) {
644
645 // This is a first-order clause.
646 if ( $this->is_first_order_clause( $clause ) ) {
647 $clause_sql = $this->get_sql_for_clause( $clause, $query );
648
649 $where_count = count( $clause_sql['where'] );
650 if ( ! $where_count ) {
651 $sql_chunks['where'][] = '';
652 } elseif ( 1 === $where_count ) {
653 $sql_chunks['where'][] = $clause_sql['where'][0];
654 } else {
655 $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
656 }
657
658 $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
659 // This is a subquery, so we recurse.
660 } else {
661 $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
662
663 $sql_chunks['where'][] = $clause_sql['where'];
664 $sql_chunks['join'][] = $clause_sql['join'];
665 }
666 }
667 }
668
669 // Filter to remove empties.
670 $sql_chunks['join'] = array_filter( $sql_chunks['join'] );
671 $sql_chunks['where'] = array_filter( $sql_chunks['where'] );
672
673 if ( empty( $relation ) ) {
674 $relation = 'AND';
675 }
676
677 // Filter duplicate JOIN clauses and combine into a single string.
678 if ( ! empty( $sql_chunks['join'] ) ) {
679 $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) );
680 }
681
682 // Generate a single WHERE clause with proper brackets and indentation.
683 if ( ! empty( $sql_chunks['where'] ) ) {
684 $sql['where'] = '( ' . "\n " . $indent . implode( ' ' . "\n " . $indent . $relation . ' ' . "\n " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')';
685 }
686
687 return $sql;
688 }
689
690 /**
691 * Turns a single date clause into pieces for a WHERE clause.
692 *
693 * A wrapper for get_sql_for_clause(), included here for backward
694 * compatibility while retaining the naming convention across Query classes.
695 *
696 * @since 3.7.0
697 *
698 * @param array $query Date query arguments.
699 * @return array {
700 * Array containing JOIN and WHERE SQL clauses to append to the main query.
701 *
702 * @type string[] $join Array of SQL fragments to append to the main JOIN clause.
703 * @type string[] $where Array of SQL fragments to append to the main WHERE clause.
704 * }
705 */
706 protected function get_sql_for_subquery( $query ) {
707 return $this->get_sql_for_clause( $query, '' );
708 }
709
710 /**
711 * Turns a first-order date query into SQL for a WHERE clause.
712 *
713 * @since 4.1.0
714 *
715 * @global wpdb $wpdb WordPress database abstraction object.
716 *
717 * @param array $query Date query clause.
718 * @param array $parent_query Parent query of the current date query.
719 * @return array {
720 * Array containing JOIN and WHERE SQL clauses to append to the main query.
721 *
722 * @type string[] $join Array of SQL fragments to append to the main JOIN clause.
723 * @type string[] $where Array of SQL fragments to append to the main WHERE clause.
724 * }
725 */
726 protected function get_sql_for_clause( $query, $parent_query ) {
727 global $wpdb;
728
729 // The sub-parts of a $where part.
730 $where_parts = array();
731
732 $column = ( ! empty( $query['column'] ) ) ? esc_sql( $query['column'] ) : $this->column;
733
734 $column = $this->validate_column( $column );
735
736 $compare = $this->get_compare( $query );
737
738 $inclusive = ! empty( $query['inclusive'] );
739
740 // Assign greater- and less-than values.
741 $lt = '<';
742 $gt = '>';
743
744 if ( $inclusive ) {
745 $lt .= '=';
746 $gt .= '=';
747 }
748
749 // Range queries.
750 if ( ! empty( $query['after'] ) ) {
751 $where_parts[] = $wpdb->prepare( "$column $gt %s", $this->build_mysql_datetime( $query['after'], ! $inclusive ) );
752 }
753 if ( ! empty( $query['before'] ) ) {
754 $where_parts[] = $wpdb->prepare( "$column $lt %s", $this->build_mysql_datetime( $query['before'], $inclusive ) );
755 }
756 // Specific value queries.
757
758 $date_units = array(
759 'YEAR' => array( 'year' ),
760 'MONTH' => array( 'month', 'monthnum' ),
761 '_wp_mysql_week' => array( 'week', 'w' ),
762 'DAYOFYEAR' => array( 'dayofyear' ),
763 'DAYOFMONTH' => array( 'day' ),
764 'DAYOFWEEK' => array( 'dayofweek' ),
765 'WEEKDAY' => array( 'dayofweek_iso' ),
766 );
767
768 // Check of the possible date units and add them to the query.
769 foreach ( $date_units as $sql_part => $query_parts ) {
770 foreach ( $query_parts as $query_part ) {
771 if ( isset( $query[ $query_part ] ) ) {
772 $value = $this->build_value( $compare, $query[ $query_part ] );
773 if ( $value ) {
774 switch ( $sql_part ) {
775 case '_wp_mysql_week':
776 $where_parts[] = _wp_mysql_week( $column ) . " $compare $value";
777 break;
778 case 'WEEKDAY':
779 $where_parts[] = "$sql_part( $column ) + 1 $compare $value";
780 break;
781 default:
782 $where_parts[] = "$sql_part( $column ) $compare $value";
783 }
784
785 break;
786 }
787 }
788 }
789 }
790
791 if ( isset( $query['hour'] ) || isset( $query['minute'] ) || isset( $query['second'] ) ) {
792 // Avoid notices.
793 foreach ( array( 'hour', 'minute', 'second' ) as $unit ) {
794 if ( ! isset( $query[ $unit ] ) ) {
795 $query[ $unit ] = null;
796 }
797 }
798
799 $time_query = $this->build_time_query( $column, $compare, $query['hour'], $query['minute'], $query['second'] );
800 if ( $time_query ) {
801 $where_parts[] = $time_query;
802 }
803 }
804
805 /*
806 * Return an array of 'join' and 'where' for compatibility
807 * with other query classes.
808 */
809 return array(
810 'where' => $where_parts,
811 'join' => array(),
812 );
813 }
814
815 /**
816 * Builds and validates a value string based on the comparison operator.
817 *
818 * @since 3.7.0
819 *
820 * @param string $compare The compare operator to use.
821 * @param string|array $value The value.
822 * @return string|false|int The value to be used in SQL or false on error.
823 */
824 public function build_value( $compare, $value ) {
825 if ( ! isset( $value ) ) {
826 return false;
827 }
828
829 switch ( $compare ) {
830 case 'IN':
831 case 'NOT IN':
832 $value = (array) $value;
833
834 // Remove non-numeric values.
835 $value = array_filter( $value, 'is_numeric' );
836
837 if ( empty( $value ) ) {
838 return false;
839 }
840
841 return '(' . implode( ',', array_map( 'intval', $value ) ) . ')';
842
843 case 'BETWEEN':
844 case 'NOT BETWEEN':
845 if ( ! is_array( $value ) || 2 !== count( $value ) ) {
846 $value = array( $value, $value );
847 } else {
848 $value = array_values( $value );
849 }
850
851 // If either value is non-numeric, bail.
852 foreach ( $value as $v ) {
853 if ( ! is_numeric( $v ) ) {
854 return false;
855 }
856 }
857
858 $value = array_map( 'intval', $value );
859
860 return $value[0] . ' AND ' . $value[1];
861
862 default:
863 if ( ! is_numeric( $value ) ) {
864 return false;
865 }
866
867 return (int) $value;
868 }
869 }
870
871 /**
872 * Builds a MySQL format date/time based on some query parameters.
873 *
874 * You can pass an array of values (year, month, etc.) with missing parameter values being defaulted to
875 * either the maximum or minimum values (controlled by the $default_to parameter). Alternatively you can
876 * pass a string that will be passed to date_create().
877 *
878 * @since 3.7.0
879 *
880 * @param string|array $datetime An array of parameters or a strtotime() string.
881 * @param bool $default_to_max Whether to round up incomplete dates. Supported by values
882 * of $datetime that are arrays, or string values that are a
883 * subset of MySQL date format ('Y', 'Y-m', 'Y-m-d', 'Y-m-d H:i').
884 * Default: false.
885 * @return string|false A MySQL format date/time or false on failure.
886 */
887 public function build_mysql_datetime( $datetime, $default_to_max = false ) {
888 if ( ! is_array( $datetime ) ) {
889
890 /*
891 * Try to parse some common date formats, so we can detect
892 * the level of precision and support the 'inclusive' parameter.
893 */
894 if ( preg_match( '/^(\d{4})$/', $datetime, $matches ) ) {
895 // Y
896 $datetime = array(
897 'year' => (int) $matches[1],
898 );
899
900 } elseif ( preg_match( '/^(\d{4})\-(\d{2})$/', $datetime, $matches ) ) {
901 // Y-m
902 $datetime = array(
903 'year' => (int) $matches[1],
904 'month' => (int) $matches[2],
905 );
906
907 } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2})$/', $datetime, $matches ) ) {
908 // Y-m-d
909 $datetime = array(
910 'year' => (int) $matches[1],
911 'month' => (int) $matches[2],
912 'day' => (int) $matches[3],
913 );
914
915 } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2}) (\d{2}):(\d{2})$/', $datetime, $matches ) ) {
916 // Y-m-d H:i
917 $datetime = array(
918 'year' => (int) $matches[1],
919 'month' => (int) $matches[2],
920 'day' => (int) $matches[3],
921 'hour' => (int) $matches[4],
922 'minute' => (int) $matches[5],
923 );
924 }
925
926 // If no match is found, we don't support default_to_max.
927 if ( ! is_array( $datetime ) ) {
928 $wp_timezone = wp_timezone();
929
930 // Assume local timezone if not provided.
931 $dt = date_create( $datetime, $wp_timezone );
932
933 if ( false === $dt ) {
934 return gmdate( 'Y-m-d H:i:s', false );
935 }
936
937 return $dt->setTimezone( $wp_timezone )->format( 'Y-m-d H:i:s' );
938 }
939 }
940
941 $datetime = array_map( 'absint', $datetime );
942
943 if ( ! isset( $datetime['year'] ) ) {
944 $datetime['year'] = current_time( 'Y' );
945 }
946
947 if ( ! isset( $datetime['month'] ) ) {
948 $datetime['month'] = ( $default_to_max ) ? 12 : 1;
949 }
950
951 if ( ! isset( $datetime['day'] ) ) {
952 $datetime['day'] = ( $default_to_max ) ? (int) gmdate( 't', mktime( 0, 0, 0, $datetime['month'], 1, $datetime['year'] ) ) : 1;
953 }
954
955 if ( ! isset( $datetime['hour'] ) ) {
956 $datetime['hour'] = ( $default_to_max ) ? 23 : 0;
957 }
958
959 if ( ! isset( $datetime['minute'] ) ) {
960 $datetime['minute'] = ( $default_to_max ) ? 59 : 0;
961 }
962
963 if ( ! isset( $datetime['second'] ) ) {
964 $datetime['second'] = ( $default_to_max ) ? 59 : 0;
965 }
966
967 return sprintf( '%04d-%02d-%02d %02d:%02d:%02d', $datetime['year'], $datetime['month'], $datetime['day'], $datetime['hour'], $datetime['minute'], $datetime['second'] );
968 }
969
970 /**
971 * Builds a query string for comparing time values (hour, minute, second).
972 *
973 * If just hour, minute, or second is set than a normal comparison will be done.
974 * However if multiple values are passed, a pseudo-decimal time will be created
975 * in order to be able to accurately compare against.
976 *
977 * @since 3.7.0
978 *
979 * @global wpdb $wpdb WordPress database abstraction object.
980 *
981 * @param string $column The column to query against. Needs to be pre-validated!
982 * @param string $compare The comparison operator. Needs to be pre-validated!
983 * @param int|null $hour Optional. An hour value (0-23).
984 * @param int|null $minute Optional. A minute value (0-59).
985 * @param int|null $second Optional. A second value (0-59).
986 * @return string|false A query part or false on failure.
987 */
988 public function build_time_query( $column, $compare, $hour = null, $minute = null, $second = null ) {
989 global $wpdb;
990
991 // Have to have at least one.
992 if ( ! isset( $hour ) && ! isset( $minute ) && ! isset( $second ) ) {
993 return false;
994 }
995
996 // Complex combined queries aren't supported for multi-value queries.
997 if ( in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
998 $return = array();
999
1000 $value = $this->build_value( $compare, $hour );
1001 if ( false !== $value ) {
1002 $return[] = "HOUR( $column ) $compare $value";
1003 }
1004
1005 $value = $this->build_value( $compare, $minute );
1006 if ( false !== $value ) {
1007 $return[] = "MINUTE( $column ) $compare $value";
1008 }
1009
1010 $value = $this->build_value( $compare, $second );
1011 if ( false !== $value ) {
1012 $return[] = "SECOND( $column ) $compare $value";
1013 }
1014
1015 return implode( ' AND ', $return );
1016 }
1017
1018 // Cases where just one unit is set.
1019 if ( isset( $hour ) && ! isset( $minute ) && ! isset( $second ) ) {
1020 $value = $this->build_value( $compare, $hour );
1021 if ( false !== $value ) {
1022 return "HOUR( $column ) $compare $value";
1023 }
1024 } elseif ( ! isset( $hour ) && isset( $minute ) && ! isset( $second ) ) {
1025 $value = $this->build_value( $compare, $minute );
1026 if ( false !== $value ) {
1027 return "MINUTE( $column ) $compare $value";
1028 }
1029 } elseif ( ! isset( $hour ) && ! isset( $minute ) && isset( $second ) ) {
1030 $value = $this->build_value( $compare, $second );
1031 if ( false !== $value ) {
1032 return "SECOND( $column ) $compare $value";
1033 }
1034 }
1035
1036 // Single units were already handled. Since hour & second isn't allowed, minute must to be set.
1037 if ( ! isset( $minute ) ) {
1038 return false;
1039 }
1040
1041 $format = '';
1042 $time = '';
1043
1044 // Hour.
1045 if ( null !== $hour ) {
1046 $format .= '%H.';
1047 $time .= sprintf( '%02d', $hour ) . '.';
1048 } else {
1049 $format .= '0.';
1050 $time .= '0.';
1051 }
1052
1053 // Minute.
1054 $format .= '%i';
1055 $time .= sprintf( '%02d', $minute );
1056
1057 if ( isset( $second ) ) {
1058 $format .= '%s';
1059 $time .= sprintf( '%02d', $second );
1060 }
1061
1062 return $wpdb->prepare( "DATE_FORMAT( $column, %s ) $compare %f", $format, $time );
1063 }
1064
1065 /**
1066 * Sanitizes a 'relation' operator.
1067 *
1068 * @since 6.0.3
1069 *
1070 * @param string $relation Raw relation key from the query argument.
1071 * @return string Sanitized relation. Either 'AND' or 'OR'.
1072 */
1073 public function sanitize_relation( $relation ) {
1074 if ( 'OR' === strtoupper( $relation ) ) {
1075 return 'OR';
1076 } else {
1077 return 'AND';
1078 }
1079 }
1080}
1081