[COT] `query()` method: add support for date queries (#34119)

* Add some date-related query arg mappings

* Add `join()` utility method

* Add support for date_query

* Add date_query tests

* Add changelog

* Address feedback

* ‘type’ not yet implemented

* Remap legacy date columns too
This commit is contained in:
Jorge A. Torres 2022-08-05 00:29:12 -03:00 committed by GitHub
parent 5becf47d60
commit 7a1999ec86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 429 additions and 5 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Adds support for date_query to the COT datastore.

View File

@ -20,6 +20,11 @@ class OrdersTableQuery {
*/
public const SKIPPED_VALUES = array( '', array(), null );
/**
* Regex used to catch "shorthand" comparisons in date-related query args.
*/
public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';
/**
* Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
*
@ -118,6 +123,13 @@ class OrdersTableQuery {
*/
private $meta_query = null;
/**
* Date query parser.
*
* @var WP_Date_Query
*/
private $date_query = null;
/**
* Sets up and runs the query after processing arguments.
@ -139,7 +151,7 @@ class OrdersTableQuery {
$this->args = $args;
// TODO: args to be implemented.
unset( $this->args['type'], $this->args['customer_note'], $this->args['name'] );
unset( $this->args['customer_note'], $this->args['name'] );
$this->build_query();
$this->run_query();
@ -154,7 +166,9 @@ class OrdersTableQuery {
$mapping = array(
// WP_Query legacy.
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
'post_status' => 'status',
'_date_completed' => 'date_completed_gmt',
'_date_paid' => 'date_paid_gmt',
@ -182,7 +196,9 @@ class OrdersTableQuery {
'version' => 'woocommerce_version',
'date_created' => 'date_created_gmt',
'date_modified' => 'date_updated_gmt',
'date_modified_gmt' => 'date_updated_gmt',
'date_completed' => 'date_completed_gmt',
'date_completed_gmt' => 'date_completed_gmt',
'date_paid' => 'date_paid_gmt',
'discount_total' => 'discount_total_amount',
'discount_tax' => 'discount_tax_amount',
@ -225,6 +241,182 @@ class OrdersTableQuery {
}
}
/**
* Generates a `WP_Date_Query` compatible query from a given date.
* 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.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
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( 'UTC' ) );
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// YYYY-MM-DD queries have 'day' precision for backwards compat.
$date = wc_string_to_datetime( $date );
$precision = 'day';
}
$result['year'] = $date->date( 'Y' );
$result['month'] = $date->date( 'm' );
$result['day'] = $date->date( 'd' );
if ( 'second' === $precision ) {
$result['hour'] = $date->date( 'H' );
$result['minute'] = $date->date( 'i' );
$result['second'] = $date->date( 's' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
* @return void
* @throws \Exception When date args are invalid.
*/
private function process_date_args(): void {
$valid_operators = array( '>', '>=', '=', '<=', '<', '...' );
$date_queries = array();
$gmt_date_keys = array(
'date_created_gmt',
'date_updated_gmt',
'date_paid_gmt',
'date_completed_gmt',
);
foreach ( array_filter( $gmt_date_keys, array( $this, 'arg_isset' ) ) as $date_key ) {
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates = array();
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] );
}
$dates[] = $this->date_to_date_query_arg( $matches[3] );
} else {
$dates[] = $this->date_to_date_query_arg( $date_value );
}
if ( empty( $dates ) || ! $operator || ( '...' === $operator && count( $dates ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
$operator_to_keys[] = 'after';
}
if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
$operator_to_keys[] = 'before';
}
$date_queries[] = array_merge(
array(
'column' => $date_key,
'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
),
'=' === $operator
? end( $dates )
: array_combine( $operator_to_keys, $dates )
);
}
// Add top-level date parameters to the date_query.
$tl_query = array();
foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
if ( $this->arg_isset( $tl_key ) ) {
$tl_query[ $tl_key ] = $this->args[ $tl_key ];
unset( $this->args[ $tl_key ] );
}
}
if ( $tl_query ) {
$tl_query['column'] = 'date_created_gmt';
$date_queries[] = $tl_query;
}
if ( $date_queries ) {
if ( ! $this->arg_isset( 'date_query' ) ) {
$this->args['date_query'] = array();
}
$this->args['date_query'] = array_merge(
array( 'relation' => 'AND' ),
$date_queries,
$this->args['date_query']
);
}
$this->process_date_query_columns();
}
/**
* Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
*
* @return void
*/
private function process_date_query_columns() {
global $wpdb;
$legacy_columns = array(
'post_date' => 'date_created_gmt',
'post_date_gmt' => 'date_created_gmt',
'post_modified' => 'date_modified_gmt',
'post_modified_gmt' => 'date_updated_gmt',
);
$table_mapping = array(
'date_created_gmt' => $this->tables['orders'],
'date_updated_gmt' => $this->tables['orders'],
'date_paid_gmt' => $this->tables['operational_data'],
'date_completed_gmt' => $this->tables['operational_data'],
);
if ( empty( $this->args['date_query'] ) ) {
return;
}
array_walk_recursive(
$this->args['date_query'],
function( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
if ( 'column' !== $key ) {
return;
}
// Translate legacy columns from wp_posts if necessary.
$value =
( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
? $legacy_columns[ $value ]
: $value;
$table = $table_mapping[ $value ] ?? null;
if ( ! $table ) {
return;
}
$value = "{$table}.{$value}";
if ( $table !== $this->tables['orders'] ) {
$this->join( $table, '', '', 'inner', true );
}
}
);
}
/**
* Sanitizes the 'status' query var.
*
@ -313,6 +505,7 @@ class OrdersTableQuery {
$this->maybe_remap_args();
// Build query.
$this->process_date_args();
$this->process_orders_table_query_args();
$this->process_operational_data_table_query_args();
$this->process_addresses_table_query_args();
@ -331,6 +524,12 @@ class OrdersTableQuery {
}
}
// Date queries.
if ( ! empty( $this->args['date_query'] ) ) {
$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
$this->where[] = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
}
$this->process_orderby();
$this->process_limit();
@ -371,6 +570,55 @@ class OrdersTableQuery {
$this->sql = "SELECT $found_rows $distinct $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
}
/**
* JOINs the main orders table with another table.
*
* @param string $table Table name (including prefix).
* @param string $alias Table alias to use. Defaults to $table.
* @param string $on ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
* @param string $join_type JOIN type: LEFT, RIGHT or INNER.
* @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
* @return void
* @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
*/
private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
$alias = empty( $alias ) ? $table : $alias;
$join_type = strtoupper( trim( $join_type ) );
if ( $this->tables['orders'] === $alias ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
}
if ( empty( $on ) ) {
if ( $this->tables['orders'] === $table ) {
$on = "{$this->tables['orders']}.id = {$alias}.id";
} else {
$on = "{$this->tables['orders']}.id = {$alias}.order_id";
}
}
if ( isset( $this->join[ $alias ] ) ) {
if ( ! $alias_once ) {
// translators: %s is a table name.
throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
}
return;
}
if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
$join_type = 'INNER';
}
$sql_join = '';
$sql_join .= "{$join_type} JOIN {$table} ";
$sql_join .= ( $alias !== $table ) ? "AS {$alias} " : '';
$sql_join .= "ON ( {$on} )";
$this->join[ $alias ] = $sql_join;
}
/**
* Generates a properly escaped and sanitized WHERE condition for a given field.
*
@ -430,6 +678,9 @@ class OrdersTableQuery {
private function process_orders_table_query_args(): void {
$this->sanitize_status();
// TODO: not yet implemented.
unset( $this->args['type'] );
$fields = array_filter(
array(
'id',
@ -530,7 +781,13 @@ class OrdersTableQuery {
return;
}
$this->join[] = "INNER JOIN {$this->tables['operational_data']} ON ( {$this->tables['orders']}.id = {$this->tables['operational_data']}.order_id )";
$this->join(
$this->tables['operational_data'],
'',
'',
'inner',
true
);
foreach ( $fields as $arg_key ) {
$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
@ -566,9 +823,12 @@ class OrdersTableQuery {
continue;
}
$this->join[] = $wpdb->prepare(
"INNER JOIN {$this->tables['addresses']} AS {$address_type} ON ( {$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$address_type
$this->join(
$this->tables['addresses'],
$address_type,
$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
'inner',
false
);
foreach ( $fields as $arg_key ) {

View File

@ -630,6 +630,166 @@ class OrdersTableDataStoreTests extends WC_Unit_Test_Case {
}
/**
* Tests queries involving 'date_query'.
*
* @return void
*/
public function test_cot_query_date_query() {
// Hardcode a day so that we don't go over to a different month or year by adding/substracting hours and days.
$now = strtotime( '2022-06-04 10:00:00' );
$deltas = array(
-DAY_IN_SECONDS,
-HOUR_IN_SECONDS,
0,
HOUR_IN_SECONDS,
DAY_IN_SECONDS,
YEAR_IN_SECONDS,
);
foreach ( $deltas as $delta ) {
$time = $now + $delta;
$order = new \WC_Order();
$this->switch_data_store( $order, $this->sut );
$order->set_date_created( $time );
$order->set_date_paid( $time + HOUR_IN_SECONDS );
$order->set_date_completed( $time + ( 2 * HOUR_IN_SECONDS ) );
$order->save();
}
// Orders exactly created at $now.
$query = new OrdersTableQuery(
array(
'date_created_gmt' => $now,
)
);
$this->assertCount( 1, $query->orders );
// Orders created since $now (inclusive).
$query = new OrdersTableQuery(
array(
'date_created_gmt' => '>=' . $now,
)
);
$this->assertCount( 4, $query->orders );
// Orders created before $now (inclusive).
$query = new OrdersTableQuery(
array(
'date_created_gmt' => '<=' . $now,
)
);
$this->assertCount( 3, $query->orders );
// Orders created before $now (non-inclusive).
$query = new OrdersTableQuery(
array(
'date_created_gmt' => '<' . $now,
)
);
$this->assertCount( 2, $query->orders );
// Orders created exactly between the day before yesterday and yesterday.
$query = new OrdersTableQuery(
array(
'date_created_gmt' => ( $now - ( 2 * DAY_IN_SECONDS ) ) . '...' . ( $now - DAY_IN_SECONDS ),
)
);
$this->assertCount( 1, $query->orders );
// Orders created today. Tests 'day' precision strings.
$query = new OrdersTableQuery(
array(
'date_created_gmt' => gmdate( 'Y-m-d', $now ),
)
);
$this->assertCount( 3, $query->orders );
// Orders created after today. Tests 'day' precision strings.
$query = new OrdersTableQuery(
array(
'date_created_gmt' => '>' . gmdate( 'Y-m-d', $now ),
)
);
$this->assertCount( 2, $query->orders );
// Orders created next year. Tests top-level date_query args.
$query = new OrdersTableQuery(
array(
'year' => gmdate( 'Y', $now + YEAR_IN_SECONDS ),
)
);
$this->assertCount( 1, $query->orders );
// Orders created today, paid between 11:00 and 13:00.
$query = new OrdersTableQuery(
array(
'date_created_gmt' => gmdate( 'Y-m-d', $now ),
'date_paid_gmt' => strtotime( gmdate( 'Y-m-d 11:00:00', $now ) ) . '...' . strtotime( gmdate( 'Y-m-d 13:00:00', $now ) ),
)
);
$this->assertCount( 2, $query->orders );
// Orders completed after 11:00 AM on any date. Tests meta_query directly.
$query = new OrdersTableQuery(
array(
'date_query' => array(
array(
'column' => 'date_completed_gmt',
'hour' => 11,
'compare' => '>',
),
),
)
);
$this->assertCount( 5, $query->orders );
// Orders completed last year. Should return none.
$query = new OrdersTableQuery(
array(
'date_query' => array(
array(
'column' => 'date_completed_gmt',
'year' => gmdate( 'Y', $now - YEAR_IN_SECONDS ),
'compare' => '<',
),
),
)
);
$this->assertCount( 0, $query->orders );
// Orders created between a month ago and 2 years in the future. That is, all orders.
$a_month_ago = $now - MONTH_IN_SECONDS;
$two_years_later = $now + ( 2 * YEAR_IN_SECONDS );
$query = new OrdersTableQuery(
array(
'date_query' => array(
array(
'after' => array(
'year' => gmdate( 'Y', $a_month_ago ),
'month' => gmdate( 'm', $a_month_ago ),
'day' => gmdate( 'd', $a_month_ago ),
'hour' => gmdate( 'H', $a_month_ago ),
'minute' => gmdate( 'i', $a_month_ago ),
'second' => gmdate( 's', $a_month_ago ),
),
'before' => array(
'year' => gmdate( 'Y', $two_years_later ),
'month' => gmdate( 'm', $two_years_later ),
'day' => gmdate( 'd', $two_years_later ),
'hour' => gmdate( 'H', $two_years_later ),
'minute' => gmdate( 'i', $two_years_later ),
'second' => gmdate( 's', $two_years_later ),
),
),
),
)
);
$this->assertCount( 6, $query->orders );
}
/**
* Helper function to delete all meta for post.
*