Properly convert shorthand date queries in local time to UTC (#40146)

- Allow overriding of precision in `OrdersTableQuery::date_to_date_query_arg()
- Shorthand date queries should either date-only or timestamp
- Properly convert shorthand date queries from local to UTC for querying
- Add utility function to convert local-time args to UTC
- Simplify date args processing
- Drop no longer necessary params from date_to_date_query_arg()
- Dates in orders list table filter should be local time
This commit is contained in:
Jorge A. Torres 2023-09-19 10:03:05 +01:00 committed by GitHub
parent 1b37042d55
commit fd6da30df2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 146 additions and 23 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Properly convert local time date queries to UTC in the HPOS datastore.

View File

@ -711,20 +711,15 @@ class ListTable extends WP_List_Table {
global $wpdb;
$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
$utc_offset = wc_timezone_offset();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_dates = $wpdb->get_results(
"
SELECT DISTINCT YEAR( date_created_gmt ) AS year,
MONTH( date_created_gmt ) AS month
FROM $orders_table
WHERE status NOT IN (
'trash'
)
ORDER BY year DESC, month DESC;
SELECT DISTINCT YEAR( t.date_created_local ) AS year,
MONTH( t.date_created_local ) AS month
FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE status != 'trash' ) t
ORDER BY year DESC, month DESC
"
);

View File

@ -332,22 +332,22 @@ class OrdersTableQuery {
* YYYY-MM-DD queries have 'day' precision for backwards compatibility.
*
* @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
* @param string $timezone The timezone to use for the date.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
private function date_to_date_query_arg( $date, $timezone ): array {
private function date_to_date_query_arg( $date ): array {
$result = array(
'year' => '',
'month' => '',
'day' => '',
);
$precision = 'second';
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( $timezone ) );
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
$precision = 'second';
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// YYYY-MM-DD queries have 'day' precision for backwards compat.
$date = wc_string_to_datetime( $date );
// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
$date = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
$precision = 'day';
}
@ -364,6 +364,80 @@ class OrdersTableQuery {
return $result;
}
/**
* Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
*
* @param array $dates_raw Array of dates (in local time) to use in combination with the operator.
* @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
* @return array Partial date query arg with relevant dates now UTC-based.
*
* @since 8.2.0
*/
private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
$result = array();
// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
foreach ( $dates_raw as &$raw_date ) {
$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
}
$date1 = end( $dates_raw );
switch ( $operator ) {
case '>':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => true,
);
break;
case '>=':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
);
break;
case '=':
$result = array(
'relation' => 'AND',
array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
),
array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
)
);
break;
case '<=':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
);
break;
case '<':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => false,
);
break;
case '...':
$result = array(
'relation' => 'AND',
$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
);
break;
}
if ( ! $result ) {
throw new \Exception( 'Please specify a valid date shorthand operator.' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
@ -392,27 +466,45 @@ class OrdersTableQuery {
$date_keys = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );
foreach ( $date_keys as $date_key ) {
$is_local = in_array( $date_key, $local_date_keys, true );
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates_raw = array();
$dates = array();
$timezone = in_array( $date_key, $gmt_date_keys, true ) ? '+0000' : wc_timezone_string();
if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
if ( ! empty( $matches[1] ) ) {
$dates[] = $this->date_to_date_query_arg( $matches[1], $timezone );
$dates_raw[] = $matches[1];
}
$dates[] = $this->date_to_date_query_arg( $matches[3], $timezone );
$dates_raw[] = $matches[3];
} else {
$dates[] = $this->date_to_date_query_arg( $date_value, $timezone );
$dates_raw[] = $date_value;
}
if ( empty( $dates ) || ! $operator || ( '...' === $operator && count( $dates ) < 2 ) ) {
if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
if ( $is_local ) {
$date_key = $local_to_gmt_date_keys[ $date_key ];
if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
$date_queries[] = array_merge(
array(
'column' => $date_key,
),
$this->local_time_to_gmt_date_query( $dates_raw, $operator )
);
continue;
}
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
@ -423,7 +515,7 @@ class OrdersTableQuery {
$operator_to_keys[] = 'before';
}
$date_key = in_array( $date_key, $local_date_keys, true ) ? $local_to_gmt_date_keys[ $date_key ] : $date_key;
$dates = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
$date_queries[] = array_merge(
array(
'column' => $date_key,
@ -515,7 +607,7 @@ class OrdersTableQuery {
$op = isset( $query['after'] ) ? 'after' : 'before';
$date_value_local = $query[ $op ];
$date_value_gmt = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt, 'UTC' );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt );
}
return $query;

View File

@ -2974,4 +2974,36 @@ class OrdersTableDataStoreTests extends HposTestCase {
$this->assertTrue( in_array( 'test_value4', $post_meta, true ) );
$this->assertFalse( in_array( 'test_value5', $post_meta, true ) );
}
/**
* @testDox Test that date queries correctly handle timezones.
*/
public function test_timezone_date_query_support() {
$order = new WC_Order();
$order->set_date_created( '2023-09-01T00:30:00' ); // This would be 2023-08-31T18:00:00 UTC given the current timezone.
$this->sut->create( $order );
$query = new OrdersTableQuery( array( 'date_created_gmt' => '2023-09-01' ) );
$this->assertEquals( 0, count( $query->orders ) ); // Should not return anything as the order was created on 2023-08-31 UTC.
$query = new OrdersTableQuery( array( 'date_created_gmt' => '2023-08-31' ) );
$this->assertEquals( 1, count( $query->orders ) );
$query = new OrdersTableQuery( array( 'date_created_gmt' => '<=2023-09-01' ) );
$this->assertEquals( 1, count( $query->orders ) );
$query = new OrdersTableQuery( array( 'date_created' => '2023-08-31' ) );
$this->assertEquals( 0, count( $query->orders ) );
$query = new OrdersTableQuery( array( 'date_created' => '2023-09-01' ) );
$this->assertEquals( 1, count( $query->orders ) );
$query = new OrdersTableQuery( array( 'date_created' => '>2023-09-01' ) );
$this->assertEquals( 0, count( $query->orders ) );
$query = new OrdersTableQuery( array( 'date_created' => '<2023-09-01' ) );
$this->assertEquals( 0, count( $query->orders ) );
}
}