WordPress.org

Make WordPress Core

Changeset 29923


Ignore:
Timestamp:
10/16/2014 07:33:24 PM (5 years ago)
Author:
boonebgorges
Message:

Introduce nested query support to WP_Date_Query.

This enhancement makes it possible to filter post, comment, and other queries
by date in ways that are arbitrarily complex, using mixed AND and OR relations.

Includes unit tests for the new syntax. In a few places, the existing unit
tests were slightly too strict (such as when checking the exact syntax of a SQL
string); these existing tests have been narrowed.

Props boonebgorges.
Fixes #29822.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/date.php

    r29797 r29923  
    11<?php
    22/**
    3  * WP_Date_Query will generate a MySQL WHERE clause for the specified date-based parameters.
     3 * Class for generating SQL clauses that filter a primary query according to date.
    44 *
    5  * Initialize the class by passing an array of arrays of parameters.
     5 * `WP_Date_Query` is a helper that allows primary query classes, such as {@see WP_Query},
     6 * to filter their results by date columns, by generating `WHERE` subclauses to be attached
     7 * to the primary SQL query string.
    68 *
    79 * @link http://codex.wordpress.org/Function_Reference/WP_Query Codex page.
     
    1113class WP_Date_Query {
    1214    /**
    13      * List of date queries.
     15     * Array of date queries.
     16     *
     17     * See {@see WP_Date_Query::__construct()} for information on date query arguments.
    1418     *
    1519     * @since 3.7.0
     
    2024
    2125    /**
    22      * The relation between the queries. Can be either 'AND' or 'OR' and can be changed via the query arguments.
     26     * The default relation between top-level queries. Can be either 'AND' or 'OR'.
    2327     *
    2428     * @since 3.7.0
     
    4751
    4852    /**
     53     * Supported time-related parameter keys.
     54     *
     55     * @since 4.1.0
     56     * @access public
     57     * @var array
     58     */
     59    public $time_keys = array( 'after', 'before', 'year', 'month', 'monthnum', 'week', 'w', 'dayofyear', 'day', 'dayofweek', 'hour', 'minute', 'second' );
     60
     61    /**
    4962     * Constructor.
    5063     *
    5164     * @since 3.7.0
    5265     * @since 4.0.0 The $inclusive logic was updated to include all times within the date range.
     66     * @access public
    5367     *
    5468     * @param array $date_query {
    55      *     One or more associative arrays of date query parameters.
     69     *     Array of date query clauses.
    5670     *
    5771     *     @type array {
     
    6175     *                                'comment_date_gmt'.
    6276     *         @type string $compare  Optional. The comparison operator.
    63      *                                Default '='. Accepts '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN',
     77     *                                Accepts '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN'. Default '='.
    6478     *                                'BETWEEN', 'NOT BETWEEN'.
    65      *         @type string $relation Optional. The boolean relationship between the date queryies.
    66      *                                Default 'OR'. Accepts 'OR', 'AND'.
     79     *         @type string $relation Optional. The boolean relationship between the date queries.
     80     *                                Accepts 'OR', 'AND'. Default 'OR'.
    6781     *         @type array {
     82               Optional. An array of first-order clause parameters, or another fully-formed date query.
    6883     *             @type string|array $before Optional. Date to retrieve posts before. Accepts strtotime()-compatible
    6984     *                                        string, or array of 'year', 'month', 'day' values. {
     
    110125     */
    111126    public function __construct( $date_query, $default_column = 'post_date' ) {
    112         if ( empty( $date_query ) || ! is_array( $date_query ) )
     127
     128        if ( isset( $date_query['relation'] ) && 'OR' === strtoupper( $date_query['relation'] ) ) {
     129            $this->relation = 'OR';
     130        } else {
     131            $this->relation = 'AND';
     132        }
     133
     134        if ( ! is_array( $date_query ) ) {
    113135            return;
    114 
    115         if ( isset( $date_query['relation'] ) && strtoupper( $date_query['relation'] ) == 'OR' )
    116             $this->relation = 'OR';
    117         else
    118             $this->relation = 'AND';
    119 
    120         if ( ! empty( $date_query['column'] ) )
    121             $this->column = esc_sql( $date_query['column'] );
    122         else
    123             $this->column = esc_sql( $default_column );
     136        }
     137
     138        // Support for passing time-based keys in the top level of the $date_query array.
     139        if ( ! isset( $date_query[0] ) && ! empty( $date_query ) ) {
     140            $date_query = array( $date_query );
     141        }
     142
     143        if ( empty( $date_query ) ) {
     144            return;
     145        }
     146
     147        if ( ! empty( $date_query['column'] ) ) {
     148            $date_query['column'] = esc_sql( $date_query['column'] );
     149        } else {
     150            $date_query['column'] = esc_sql( $default_column );
     151        }
    124152
    125153        $this->column = $this->validate_column( $this->column );
     
    127155        $this->compare = $this->get_compare( $date_query );
    128156
    129         // If an array of arrays wasn't passed, fix it
    130         if ( ! isset( $date_query[0] ) )
    131             $date_query = array( $date_query );
    132 
    133         $this->queries = array();
    134         foreach ( $date_query as $key => $query ) {
    135             if ( ! is_array( $query ) )
     157        $this->queries = $this->sanitize_query( $date_query );
     158
     159        return;
     160    }
     161
     162    /**
     163     * Recursive-friendly query sanitizer.
     164     *
     165     * Ensures that each query-level clause has a 'relation' key, and that
     166     * each first-order clause contains all the necessary keys from
     167     * $defaults.
     168     *
     169     * @since 4.1.0
     170     * @access public
     171     *
     172     * @param  array $query A tax_query query clause.
     173     * @return array Sanitized queries.
     174     */
     175    public function sanitize_query( $queries, $parent_query = null ) {
     176        $cleaned_query = array();
     177
     178        $defaults = array(
     179            'column'   => 'post_date',
     180            'compare'  => '=',
     181            'relation' => 'AND',
     182        );
     183
     184        // Numeric keys should always have array values.
     185        foreach ( $queries as $qkey => $qvalue ) {
     186            if ( is_numeric( $qkey ) && ! is_array( $qvalue ) ) {
     187                unset( $queries[ $qkey ] );
     188            }
     189        }
     190
     191        // Each query should have a value for each default key. Inherit from the parent when possible.
     192        foreach ( $defaults as $dkey => $dvalue ) {
     193            if ( isset( $queries[ $dkey ] ) ) {
    136194                continue;
    137 
    138             $this->queries[$key] = $query;
    139         }
     195            }
     196
     197            if ( isset( $parent_query[ $dkey ] ) ) {
     198                $queries[ $dkey ] = $parent_query[ $dkey ];
     199            } else {
     200                $queries[ $dkey ] = $dvalue;
     201            }
     202        }
     203
     204        foreach ( $queries as $key => $q ) {
     205            if ( ! is_array( $q ) || in_array( $key, $this->time_keys, true ) ) {
     206                // This is a first-order query. Trust the values and sanitize when building SQL.
     207                $cleaned_query[ $key ] = $q;
     208            } else {
     209                // Any array without a time key is another query, so we recurse.
     210                $cleaned_query[] = $this->sanitize_query( $q, $queries );
     211            }
     212        }
     213
     214        return $cleaned_query;
     215    }
     216
     217    /**
     218     * Determine whether this is a first-order clause.
     219     *
     220     * Checks to see if the current clause has any time-related keys.
     221     * If so, it's first-order.
     222     *
     223     * @param  array $query Query clause.
     224     * @return bool True if this is a first-order clause.
     225     */
     226    protected function is_first_order_clause( $query ) {
     227        $time_keys = array_intersect( $this->time_keys, array_keys( $query ) );
     228        return ! empty( $time_keys );
    140229    }
    141230
     
    146235     * @access public
    147236     *
    148      * @param array $query A date query or a date subquery
    149      * @return string The comparison operator
     237     * @param array $query A date query or a date subquery.
     238     * @return string The comparison operator.
    150239     */
    151240    public function get_compare( $query ) {
     
    185274
    186275    /**
    187      * Turns an array of date query parameters into a MySQL string.
    188      *
    189      * @since 3.7.0
    190      * @access public
    191      *
    192      * @return string MySQL WHERE parameters
     276     * Generate WHERE clause to be appended to a main query.
     277     *
     278     * @since 3.7.0
     279     * @access public
     280     *
     281     * @return string MySQL WHERE clause.
    193282     */
    194283    public function get_sql() {
    195         // The parts of the final query
    196         $where = array();
    197 
    198         foreach ( $this->queries as $key => $query ) {
    199             $where_parts = $this->get_sql_for_subquery( $query );
    200             if ( $where_parts ) {
    201                 // Combine the parts of this subquery into a single string
    202                 $where[ $key ] = '( ' . implode( ' AND ', $where_parts ) . ' )';
    203             }
    204         }
    205 
    206         // Combine the subquery strings into a single string
    207         if ( $where )
    208             $where = ' AND ( ' . implode( " {$this->relation} ", $where ) . ' )';
    209         else
    210             $where = '';
     284        $sql = $this->get_sql_clauses();
     285
     286        $where = $sql['where'];
    211287
    212288        /**
     
    222298
    223299    /**
    224      * Turns a single date subquery into pieces for a WHERE clause.
    225      *
    226      * @since 3.7.0
    227      * return array
     300     * Generate SQL clauses to be appended to a main query.
     301     *
     302     * Called by the public {@see WP_Date_Query::get_sql()}, this method
     303     * is abstracted out to maintain parity with the other Query classes.
     304     *
     305     * @since 4.1.0
     306     * @access protected
     307     *
     308     * @return array {
     309     *     Array containing JOIN and WHERE SQL clauses to append to the main query.
     310     *
     311     *     @type string $join  SQL fragment to append to the main JOIN clause.
     312     *     @type string $where SQL fragment to append to the main WHERE clause.
     313     * }
     314     */
     315    protected function get_sql_clauses() {
     316        $sql = $this->get_sql_for_query( $this->queries );
     317
     318        if ( ! empty( $sql['where'] ) ) {
     319            $sql['where'] = ' AND ' . $sql['where'];
     320        }
     321
     322        return $sql;
     323    }
     324
     325    /**
     326     * Generate SQL clauses for a single query array.
     327     *
     328     * If nested subqueries are found, this method recurses the tree to
     329     * produce the properly nested SQL.
     330     *
     331     * @since 4.1.0
     332     * @access protected
     333     *
     334     * @param array $query Query to parse.
     335     * @param int   $depth Optional. Number of tree levels deep we currently are.
     336     *                     Used to calculate indentation.
     337     * @return array {
     338     *     Array containing JOIN and WHERE SQL clauses to append to a single query array.
     339     *
     340     *     @type string $join  SQL fragment to append to the main JOIN clause.
     341     *     @type string $where SQL fragment to append to the main WHERE clause.
     342     * }
     343     */
     344    protected function get_sql_for_query( $query, $depth = 0 ) {
     345        $sql_chunks = array(
     346            'join'  => array(),
     347            'where' => array(),
     348        );
     349
     350        $sql = array(
     351            'join'  => '',
     352            'where' => '',
     353        );
     354
     355        $indent = '';
     356        for ( $i = 0; $i < $depth; $i++ ) {
     357            $indent .= "  ";
     358        }
     359
     360        foreach ( $query as $key => $clause ) {
     361            if ( 'relation' === $key ) {
     362                $relation = $query['relation'];
     363            } else if ( is_array( $clause ) ) {
     364
     365                // This is a first-order clause.
     366                if ( $this->is_first_order_clause( $clause ) ) {
     367                    $clause_sql = $this->get_sql_for_clause( $clause, $query );
     368
     369                    $where_count = count( $clause_sql['where'] );
     370                    if ( ! $where_count ) {
     371                        $sql_chunks['where'][] = '';
     372                    } else if ( 1 === $where_count ) {
     373                        $sql_chunks['where'][] = $clause_sql['where'][0];
     374                    } else {
     375                        $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
     376                    }
     377
     378                    $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
     379                // This is a subquery, so we recurse.
     380                } else {
     381                    $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
     382
     383                    $sql_chunks['where'][] = $clause_sql['where'];
     384                    $sql_chunks['join'][]  = $clause_sql['join'];
     385                }
     386            }
     387        }
     388
     389        // Filter to remove empties.
     390        $sql_chunks['join']  = array_filter( $sql_chunks['join'] );
     391        $sql_chunks['where'] = array_filter( $sql_chunks['where'] );
     392
     393        if ( empty( $relation ) ) {
     394            $relation = 'AND';
     395        }
     396
     397        // Filter duplicate JOIN clauses and combine into a single string.
     398        if ( ! empty( $sql_chunks['join'] ) ) {
     399            $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) );
     400        }
     401
     402        // Generate a single WHERE clause with proper brackets and indentation.
     403        if ( ! empty( $sql_chunks['where'] ) ) {
     404            $sql['where'] = '( ' . "\n  " . $indent . implode( ' ' . "\n  " . $indent . $relation . ' ' . "\n  " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')';
     405        }
     406
     407        return $sql;
     408    }
     409
     410    /**
     411     * Turns a single date clause into pieces for a WHERE clause.
     412     *
     413     * A wrapper for get_sql_for_clause(), included here for backward
     414     * compatibility while retaining the naming convention across Query classes.
     415     *
     416     * @since  3.7.0
     417     * @access protected
     418     *
     419     * @param  array $query Date query arguments.
     420     * @return array {
     421     *     Array containing JOIN and WHERE SQL clauses to append to the main query.
     422     *
     423     *     @type string $join  SQL fragment to append to the main JOIN clause.
     424     *     @type string $where SQL fragment to append to the main WHERE clause.
     425     * }
    228426     */
    229427    protected function get_sql_for_subquery( $query ) {
     428        return $this->get_sql_for_clause( $query, '' );
     429    }
     430
     431    /**
     432     * Turns a first-order date query into SQL for a WHERE clause.
     433     *
     434     * @since  4.1.0
     435     * @access protected
     436     *
     437     * @param  array $query        Date query clause.
     438     * @param  array $parent_query Parent query of the current date query.
     439     * @return array {
     440     *     Array containing JOIN and WHERE SQL clauses to append to the main query.
     441     *
     442     *     @type string $join  SQL fragment to append to the main JOIN clause.
     443     *     @type string $where SQL fragment to append to the main WHERE clause.
     444     * }
     445     */
     446    protected function get_sql_for_clause( $query, $parent_query ) {
    230447        global $wpdb;
    231448
    232         // The sub-parts of a $where part
     449        // The sub-parts of a $where part.
    233450        $where_parts = array();
    234451
     
    250467        }
    251468
    252         // Range queries
     469        // Range queries.
    253470        if ( ! empty( $query['after'] ) )
    254471            $where_parts[] = $wpdb->prepare( "$column $gt %s", $this->build_mysql_datetime( $query['after'], ! $inclusive ) );
     
    257474            $where_parts[] = $wpdb->prepare( "$column $lt %s", $this->build_mysql_datetime( $query['before'], $inclusive ) );
    258475
    259         // Specific value queries
     476        // Specific value queries.
    260477
    261478        if ( isset( $query['year'] ) && $value = $this->build_value( $compare, $query['year'] ) )
     
    282499
    283500        if ( isset( $query['hour'] ) || isset( $query['minute'] ) || isset( $query['second'] ) ) {
    284             // Avoid notices
     501            // Avoid notices.
    285502            foreach ( array( 'hour', 'minute', 'second' ) as $unit ) {
    286                 if ( ! isset( $query[$unit] ) ) {
    287                     $query[$unit] = null;
     503                if ( ! isset( $query[ $unit ] ) ) {
     504                    $query[ $unit ] = null;
    288505                }
    289506            }
     
    294511        }
    295512
    296         return $where_parts;
     513        /*
     514         * Return an array of 'join' and 'where' for compatibility
     515         * with other query classes.
     516         */
     517        return array(
     518            'where' => $where_parts,
     519            'join'  => array(),
     520        );
    297521    }
    298522
  • trunk/tests/phpunit/tests/date/query.php

    r29906 r29923  
    5656                    'month' => 6,
    5757                ),
     58                'column' => 'post_date',
     59                'compare' => '=',
     60                'relation' => 'AND',
    5861            ),
     62            'column' => 'post_date',
     63            'compare' => '=',
     64            'relation' => 'AND',
    5965        );
    6066
     
    7480        ) );
    7581
    76         // Note: WP_Date_Query does not reset indexes
    7782        $expected = array(
    78             2 => array(
     83            array(
    7984                'before' => array(
    8085                    'year' => 2008,
    8186                    'month' => 6,
    8287                ),
     88                'column' => 'post_date',
     89                'compare' => '=',
     90                'relation' => 'AND',
    8391            ),
     92            'column' => 'post_date',
     93            'compare' => '=',
     94            'relation' => 'AND',
    8495        );
    8596
    86         $this->assertSame( $expected, $q->queries );
     97        $this->assertEquals( $expected, $q->queries );
    8798    }
    8899
  • trunk/tests/phpunit/tests/query/dateQuery.php

    r29885 r29923  
    630630        $this->assertEquals( $expected_dates, wp_list_pluck( $posts, 'post_date' ) );
    631631
    632         $this->assertContains( "AND ( ( MONTH( post_date ) = 5 ) ) AND", $this->q->request );
    633 
    634         $this->assertNotContains( "AND ( ( MONTH( post_date ) = 5 AND MONTH( post_date ) = 9 ) ) AND", $this->q->request );
     632        $this->assertContains( "MONTH( post_date ) = 5", $this->q->request );
     633        $this->assertNotContains( "MONTH( post_date ) = 9", $this->q->request );
    635634    }
    636635
     
    654653        $this->assertEquals( $expected_dates, wp_list_pluck( $posts, 'post_date' ) );
    655654
    656         $this->assertContains( "AND ( ( WEEK( post_date, 1 ) = 21 ) ) AND", $this->q->request );
    657 
    658         $this->assertNotContains( "AND ( ( WEEK( post_date, 1 ) = 21 AND WEEK( post_date, 1 ) = 22 ) ) AND", $this->q->request );
     655        $this->assertContains( "WEEK( post_date, 1 ) = 21", $this->q->request );
     656        $this->assertNotContains( "WEEK( post_date, 1 ) = 22", $this->q->request );
     657    }
     658
     659    /**
     660     * @ticket 29822
     661     */
     662    public function test_date_query_one_nested_query() {
     663        $this->create_posts();
     664
     665        $posts = $this->_get_query_result( array(
     666            'date_query' => array(
     667                'relation' => 'OR',
     668                array(
     669                    'relation' => 'AND',
     670                    array(
     671                        'year' => 2004,
     672                    ),
     673                    array(
     674                        'month' => 1,
     675                    ),
     676                ),
     677                array(
     678                    'year' => 1984,
     679                ),
     680            ),
     681        ) );
     682
     683        $expected_dates = array(
     684            '1984-07-28 19:28:56',
     685            '2004-01-03 08:54:10',
     686        );
     687
     688        $this->assertEquals( $expected_dates, wp_list_pluck( $posts, 'post_date' ) );
     689    }
     690
     691    /**
     692     * @ticket 29822
     693     */
     694    public function test_date_query_one_nested_query_multiple_columns_relation_and() {
     695        $p1 = $this->factory->post->create( array(
     696            'post_date' => '2012-03-05 15:30:55',
     697        ) );
     698        $this->update_post_modified( $p1, '2014-11-03 14:43:00' );
     699
     700        $p2 = $this->factory->post->create( array(
     701            'post_date' => '2012-05-05 15:30:55',
     702        ) );
     703        $this->update_post_modified( $p2, '2014-10-03 14:43:00' );
     704
     705        $p3 = $this->factory->post->create( array(
     706            'post_date' => '2013-05-05 15:30:55',
     707        ) );
     708        $this->update_post_modified( $p3, '2014-10-03 14:43:00' );
     709
     710        $p4 = $this->factory->post->create( array(
     711            'post_date' => '2012-02-05 15:30:55',
     712        ) );
     713        $this->update_post_modified( $p4, '2012-12-03 14:43:00' );
     714
     715        $q = new WP_Query( array(
     716            'date_query' => array(
     717                'relation' => 'AND',
     718                array(
     719                    'column' => 'post_date',
     720                    array(
     721                        'year' => 2012,
     722                    ),
     723                ),
     724                array(
     725                    'column' => 'post_modified',
     726                    array(
     727                        'year' => 2014,
     728                    ),
     729                ),
     730            ),
     731            'fields' => 'ids',
     732            'update_post_meta_cache' => false,
     733            'update_post_term_cache' => false,
     734            'post_status' => 'publish',
     735        ) );
     736
     737        $expected = array( $p1, $p2, );
     738
     739        $this->assertEqualSets( $expected, $q->posts );
     740    }
     741
     742    /**
     743     * @ticket 29822
     744     */
     745    public function test_date_query_nested_query_multiple_columns_mixed_relations() {
     746        $p1 = $this->factory->post->create( array(
     747            'post_date' => '2012-03-05 15:30:55',
     748        ) );
     749        $this->update_post_modified( $p1, '2014-11-03 14:43:00' );
     750
     751        $p2 = $this->factory->post->create( array(
     752            'post_date' => '2012-05-05 15:30:55',
     753        ) );
     754        $this->update_post_modified( $p2, '2014-10-03 14:43:00' );
     755
     756        $p3 = $this->factory->post->create( array(
     757            'post_date' => '2013-05-05 15:30:55',
     758        ) );
     759        $this->update_post_modified( $p3, '2014-10-03 14:43:00' );
     760
     761        $p4 = $this->factory->post->create( array(
     762            'post_date' => '2012-02-05 15:30:55',
     763        ) );
     764        $this->update_post_modified( $p4, '2012-12-03 14:43:00' );
     765
     766        $p5 = $this->factory->post->create( array(
     767            'post_date' => '2014-02-05 15:30:55',
     768        ) );
     769        $this->update_post_modified( $p5, '2013-12-03 14:43:00' );
     770
     771        $q = new WP_Query( array(
     772            'date_query' => array(
     773                'relation' => 'OR',
     774                array(
     775                    'relation' => 'AND',
     776                    array(
     777                        'column' => 'post_date',
     778                        array(
     779                            'day' => 05,
     780                        ),
     781                    ),
     782                    array(
     783                        'column' => 'post_date',
     784                        array(
     785                            'before' => array(
     786                                'year' => 2012,
     787                                'month' => 4,
     788                            ),
     789                        ),
     790                    ),
     791                ),
     792                array(
     793                    'column' => 'post_modified',
     794                    array(
     795                        'month' => 12,
     796                    ),
     797                ),
     798            ),
     799            'fields' => 'ids',
     800            'update_post_meta_cache' => false,
     801            'update_post_term_cache' => false,
     802            'post_status' => 'publish',
     803        ) );
     804
     805        $expected = array( $p1, $p4, $p5, );
     806        $this->assertEqualSets( $expected, $q->posts );
    659807    }
    660808
     
    693841        }
    694842    }
     843
     844    /**
     845     * There's no way to change post_modified through the API.
     846     */
     847    protected function update_post_modified( $post_id, $date ) {
     848        global $wpdb;
     849        return $wpdb->update(
     850            $wpdb->posts,
     851            array(
     852                'post_modified' => $date,
     853                'post_modified_gmt' => $date,
     854            ),
     855            array(
     856                'ID' => $post_id,
     857            ),
     858            array(
     859                '%s',
     860                '%s',
     861            ),
     862            array(
     863                '%d',
     864            )
     865        );
     866    }
    695867}
Note: See TracChangeset for help on using the changeset viewer.