[COT] `query()` method: add support for field queries (#34533)

* Bring Yoda back.

* Add method to OrdersTableQuery to obtain table info for a given order field

* First pass at field_query

* Add tests

* Add changelog

* Use backticks for table names and aliases

* Improve validation in field_query

* Add some more tests
This commit is contained in:
Jorge A. Torres 2022-10-10 05:01:00 -03:00 committed by GitHub
parent f9bea12589
commit c9c2bfbf92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 712 additions and 4 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add support for complex field queries for orders.

View File

@ -0,0 +1,356 @@
<?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
* complex queries against order fields in the database.
*
* @internal
*/
class OrdersTableFieldQuery {
/**
* List of valid SQL operators to use as field_query 'compare' values.
*
* @var array
*/
private const VALID_COMPARISON_OPERATORS = array(
'=',
'!=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'EXISTS',
'NOT EXISTS',
'RLIKE',
'REGEXP',
'NOT REGEXP',
'>',
'>=',
'<',
'<=',
'BETWEEN',
'NOT BETWEEN',
);
/**
* The original query object.
*
* @var OrdersTableQuery
*/
private $query = null;
/**
* Determines whether the field query should produce no results due to an invalid argument.
*
* @var boolean
*/
private $force_no_results = false;
/**
* Holds a sanitized version of the `field_query`.
*
* @var array
*/
private $queries = array();
/**
* JOIN clauses to add to the main SQL query.
*
* @var array
*/
private $join = array();
/**
* WHERE clauses to add to the main SQL query.
*
* @var array
*/
private $where = array();
/**
* Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
*
* @var array
*/
private $table_aliases = array();
/**
* Constructor.
*
* @param OrdersTableQuery $q The main query being performed.
*/
public function __construct( OrdersTableQuery $q ) {
$field_query = $q->get( 'field_query' );
if ( ! $field_query || ! is_array( $field_query ) ) {
return;
}
$this->query = $q;
$this->queries = $this->sanitize_query( $field_query );
$this->where = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
}
/**
* Sanitizes the field_query argument.
*
* @param array $q A field_query array.
* @return array A sanitized field query array.
* @throws \Exception When field table info is missing.
*/
private function sanitize_query( array $q ) {
$sanitized = array();
foreach ( $q as $key => $arg ) {
if ( 'relation' === $key ) {
$relation = $arg;
} elseif ( ! is_array( $arg ) ) {
continue;
} elseif ( $this->is_atomic( $arg ) ) {
if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
continue;
}
// Sanitize 'compare'.
$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';
if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
$arg['compare'] = 'IN';
}
// Sanitize 'cast'.
$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );
$field_info = $this->query->get_field_mapping_info( $arg['field'] );
if ( ! $field_info ) {
$this->force_no_results = true;
continue;
}
$arg = array_merge( $arg, $field_info );
$sanitized[ $key ] = $arg;
} else {
$sanitized_arg = $this->sanitize_query( $arg );
if ( $sanitized_arg ) {
$sanitized[ $key ] = $sanitized_arg;
}
}
}
if ( $sanitized ) {
$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
}
return $sanitized;
}
/**
* Makes sure we use an AND or OR relation. Defaults to AND.
*
* @param string $relation An unsanitized relation prop.
* @return string
*/
private function sanitize_relation( string $relation ): string {
if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
return 'OR';
}
return 'AND';
}
/**
* Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
*
* @param array $q A field query.
* @return string An SQL WHERE statement.
*/
private function process( array $q ) {
$where = '';
if ( empty( $q ) ) {
return $where;
}
if ( $this->is_atomic( $q ) ) {
$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
$where = $this->generate_where_for_clause( $q );
} else {
$relation = $q['relation'];
unset( $q['relation'] );
foreach ( $q as $query ) {
$chunks[] = $this->process( $query );
}
if ( 1 === count( $chunks ) ) {
$where = $chunks[0];
} else {
$where = '(' . implode( " {$relation} ", $chunks ) . ')';
}
}
return $where;
}
/**
* Checks whether a given field_query clause is atomic or not (i.e. not nested).
*
* @param array $q The field_query clause.
* @return boolean TRUE if atomic, FALSE otherwise.
*/
private function is_atomic( $q ) {
return isset( $q['field'] );
}
/**
* Finds a common table alias that the field_query clause can use, or creates one.
*
* @param array $q An atomic field_query clause.
* @return string A table alias for use in an SQL JOIN clause.
* @throws \Exception When table info for clause is missing.
*/
private function find_or_create_table_alias_for_clause( $q ) {
global $wpdb;
if ( ! empty( $q['alias'] ) ) {
return $q['alias'];
}
if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
}
$join = '';
if ( isset( $q['mapping_id'] ) ) {
// Re-use JOINs and aliases from OrdersTableQuery for core tables.
$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
$join = $this->query->get_core_mapping_join( $q['mapping_id'] );
} else {
$alias = $q['table'];
$join = '';
}
if ( in_array( $alias, $this->table_aliases, true ) ) {
return $alias;
}
$this->table_aliases[] = $alias;
if ( $join ) {
$this->join[] = $join;
}
return $alias;
}
/**
* Returns the correct type for a given clause 'type'.
*
* @param string $type MySQL type.
* @return string MySQL type.
*/
private function sanitize_cast_type( $type ) {
$clause_type = strtoupper( $type );
if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
return 'CHAR';
}
if ( 'NUMERIC' === $clause_type ) {
$clause_type = 'SIGNED';
}
return $clause_type;
}
/**
* Generates an SQL WHERE clause for a given field_query atomic clause.
*
* @param array $clause An atomic field_query clause.
* @return string An SQL WHERE clause or an empty string if $clause is invalid.
*/
private function generate_where_for_clause( $clause ): string {
global $wpdb;
$clause_value = $clause['value'] ?? '';
if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
if ( ! is_array( $clause_value ) ) {
$clause_value = preg_split( '/[,\s]+/', $clause_value );
}
} elseif ( is_string( $clause_value ) ) {
$clause_value = trim( $clause_value );
}
$clause_compare = $clause['compare'];
switch ( $clause_compare ) {
case 'IN':
case 'NOT IN':
$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
break;
case 'LIKE':
case 'NOT LIKE':
$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
break;
case 'EXISTS':
// EXISTS with a value is interpreted as '='.
if ( $clause_value ) {
$clause_compare = '=';
$where = $wpdb->prepare( '%s', $clause_value );
} else {
$clause_compare = 'IS NOT';
$where = 'NULL';
}
break;
case 'NOT EXISTS':
// 'value' is ignored for NOT EXISTS.
$clause_compare = 'IS';
$where = 'NULL';
break;
default:
$where = $wpdb->prepare( '%s', $clause_value );
break;
}
if ( $where ) {
if ( 'CHAR' === $clause['cast'] ) {
return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
} else {
return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
}
}
return '';
}
/**
* Returns JOIN and WHERE clauses to be appended to the main SQL query.
*
* @return array {
* @type string $join JOIN clause.
* @type string $where WHERE clause.
* }
*/
public function get_sql_clauses() {
return array(
'join' => $this->join,
'where' => $this->where ? array( $this->where ) : array(),
);
}
}

View File

@ -129,6 +129,13 @@ class OrdersTableQuery {
*/
private $found_orders = 0;
/**
* Field query parser.
*
* @var OrdersTableFieldQuery
*/
private $field_query = null;
/**
* Meta query parser.
*
@ -527,6 +534,14 @@ class OrdersTableQuery {
private function build_query(): void {
$this->maybe_remap_args();
// Field queries.
if ( ! empty( $this->args['field_query'] ) ) {
$this->field_query = new OrdersTableFieldQuery( $this );
$sql = $this->field_query->get_sql_clauses();
$this->join = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
$this->where = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
}
// Build query.
$this->process_date_args();
$this->process_orders_table_query_args();
@ -578,7 +593,7 @@ class OrdersTableQuery {
}
// JOIN.
$join = implode( ' ', $this->join );
$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );
// WHERE.
$where = '1=1';
@ -594,7 +609,7 @@ class OrdersTableQuery {
if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
list( $offset, $row_count ) = $this->limits;
$row_count = $row_count === -1 ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
$row_count = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
$limits = 'LIMIT ' . (int) $offset . ', ' . $row_count;
}
@ -604,6 +619,55 @@ class OrdersTableQuery {
$this->sql = "SELECT $found_rows DISTINCT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";
}
/**
* Returns the table alias for a given table mapping.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string Table alias.
*
* @since 7.0.0
*/
public function get_core_mapping_alias( string $mapping_id ): string {
return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
? $mapping_id
: $this->tables[ $mapping_id ];
}
/**
* Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
*
* @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
* @return string The JOIN clause.
*
* @since 7.0.0
*/
public function get_core_mapping_join( string $mapping_id ): string {
global $wpdb;
if ( 'orders' === $mapping_id ) {
return '';
}
$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );
$alias = $this->get_core_mapping_alias( $mapping_id );
$table = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
$join = '';
$join_on = '';
$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );
if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
if ( $is_address_mapping ) {
$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
}
/**
* JOINs the main orders table with another table.
*
@ -912,11 +976,11 @@ class OrdersTableQuery {
$offset = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );
// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
if ( $row_count === false || $row_count < -1 ) {
if ( false === $row_count || $row_count < -1 ) {
return;
}
if ( $offset === false && $row_count > -1 ) {
if ( false === $offset && $row_count > -1 ) {
$offset = (int) ( ( $page - 1 ) * $row_count );
}
@ -1006,4 +1070,73 @@ class OrdersTableQuery {
return $this->tables[ $table_id ];
}
/**
* Finds table and mapping information about a field or column.
*
* @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
* @return false|array {
* @type string $table Full table name where the field is located.
* @type string $mapping_id Unprefixed table or mapping name.
* @type string $field_name Name of the corresponding order field.
* @type string $column Column in $table that corresponds to the field.
* @type string $type Field type.
* }
*/
public function get_field_mapping_info( $field ) {
global $wpdb;
$result = array(
'table' => '',
'mapping_id' => '',
'field_name' => '',
'column' => '',
'column_type' => '',
);
$mappings_to_search = array();
if ( false !== strstr( $field, '.' ) ) {
list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );
$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;
if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
$result['mapping_id'] = $mapping_or_table;
$result['column'] = $field_name_or_col;
} else {
$mappings_to_search = array( $mapping_or_table );
}
}
} else {
$field_name_or_col = $field;
$mappings_to_search = array_keys( $this->mappings );
}
foreach ( $mappings_to_search as $mapping_id ) {
foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
$result['mapping_id'] = $mapping_id;
$result['column'] = $column_name;
break 2;
}
}
}
if ( ! $result['mapping_id'] || ! $result['column'] ) {
return false;
}
$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];
$result['field_name'] = $field_info['name'];
$result['column_type'] = $field_info['type'];
$result['table'] = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
? $this->tables['addresses']
: $this->tables[ $result['mapping_id'] ];
return $result;
}
}

View File

@ -1087,6 +1087,220 @@ class OrdersTableDataStoreTests extends WC_Unit_Test_Case {
);
}
/**
* Ensure field_query works as expected.
*/
public function test_cot_query_field_query(): void {
$orders_test_data = array(
array( 'Werner', 'Heisenberg', 'Unknown', 'werner_heisenberg_1', '15.0', '1901-12-05', '1976-02-01' ),
array( 'Max', 'Planck', 'Quanta', 'planck_1', '16.0', '1858-04-23', '1947-10-04' ),
array( 'Édouard', 'Roche', 'Tidal', 'roche_3', '9.99', '1820-10-17', '1883-04-27' ),
);
$order_ids = array();
// Create some test orders.
foreach ( $orders_test_data as $i => $order_data ) {
$order = new \WC_Order();
$this->switch_data_store( $order, $this->sut );
$order->set_status( 'wc-completed' );
$order->set_shipping_city( 'The Universe' );
$order->set_billing_first_name( $order_data[0] );
$order->set_billing_last_name( $order_data[1] );
$order->set_billing_city( $order_data[2] );
$order->set_order_key( $order_data[3] );
$order->set_total( $order_data[4] );
$order->add_meta_data( 'customer_birthdate', $order_data[5] );
$order->add_meta_data( 'customer_last_seen', $order_data[6] );
$order->add_meta_data( 'customer_age', absint( ( strtotime( $order_data[6] ) - strtotime( $order_data[5] ) ) / YEAR_IN_SECONDS ) );
$order_ids[] = $order->save();
}
// Relatively simple field_query.
$field_query = array(
'relation' => 'OR',
array(
'field' => 'order_key',
'value' => 'werner_heisenberg_1',
),
array(
'field' => 'order_key',
'value' => 'planck_1',
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertEqualsCanonicalizing( array( $order_ids[0], $order_ids[1] ), $query->orders );
// A more complex field_query.
$field_query = array(
array(
'field' => 'billing_first_name',
'value' => array( 'Werner', 'Max', 'Édouard' ),
'compare' => 'IN',
),
array(
'relation' => 'OR',
array(
'field' => 'billing_last_name',
'value' => 'Heisen',
'compare' => 'LIKE',
),
array(
'field' => 'billing_city',
'value' => 'Tid',
'compare' => 'LIKE',
),
),
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertEqualsCanonicalizing( array( $order_ids[0], $order_ids[2] ), $query->orders );
// Find orders with order_key ending in a number (i.e. all).
$field_query = array(
array(
'field' => 'order_key',
'value' => '[0-9]$',
'compare' => 'RLIKE'
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertEqualsCanonicalizing( $order_ids, $query->orders );
// Find orders with order_key not ending in a number (i.e. none).
$field_query = array(
array(
'field' => 'order_key',
'value' => '[^0-9]$',
'compare' => 'NOT RLIKE'
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Use full column name in a query.
$field_query = array(
array(
'field' => $GLOBALS['wpdb']->prefix . 'wc_orders.total_amount',
'value' => '10.0',
'compare' => '<=',
'type' => 'NUMERIC',
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertEqualsCanonicalizing( array( $order_ids[2] ), $query->orders );
// Pass an invalid column name.
$field_query = array(
array(
'field' => 'non_existing_field',
'value' => 'any-value',
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Pass an apparently incorrect value to an 'IN' compare.
$field_query = array(
array(
'field' => 'wc_orders.total_amount',
'value' => 5.5,
'compare' => 'IN',
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Pass an invalid 'compare'.
$field_query = array(
array(
'field' => 'wc_orders.total_amount',
'value' => 10.0,
'compare' => 'EXOSTS',
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Pass an incomplete array for BETWEEN (treated as =).
$field_query = array(
array(
'field' => 'total',
'compare' => 'BETWEEN',
'value' => 10.0,
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Pass an incomplete array for NOT BETWEEN (treated as !=).
$field_query = array(
array(
'field' => 'total',
'compare' => 'NOT BETWEEN',
'value' => array( 1.0 ),
)
);
$query = new OrdersTableQuery( array( 'field_query' => $field_query ) );
$this->assertCount( 0, $query->posts );
// Test combinations of field_query with regular query args:
$args = array(
'id' => array( $order_ids[0], $order_ids[1] ),
);
$query = new OrdersTableQuery( $args );
// At this point 2 orders would be returned...
$this->assertEqualsCanonicalizing( array( $order_ids[0], $order_ids[1] ), $query->orders );
// ... and now just one
$args['field_query'] = array(
'relation' => 'AND',
array(
'field' => 'id',
'value' => $order_ids[1],
)
);
$query = new OrdersTableQuery( $args );
$this->assertEqualsCanonicalizing( array( $order_ids[1] ), $query->orders );
// ... and now none (no orders below < 5.0)
$args['field_query'][] = array(
'field' => 'total',
'value' => '5.0',
'compare' => '<',
);
$query = new OrdersTableQuery( $args );
$this->assertCount( 0, $query->orders );
// Now a more complex query with meta_query and date_query:
$args = array(
'shipping_address' => 'The Universe',
'field_query' => array(
array(
'field' => 'total',
'value' => array( 1.0, 11.0 ),
'compare' => 'NOT BETWEEN',
),
),
);
// this should fetch the orders from Heisenberg and Planck...
$query = new OrdersTableQuery( $args );
$this->assertEqualsCanonicalizing( array( $order_ids[0], $order_ids[1] ), $query->orders );
// ... but only Planck is more than 80 years old.
$args['meta_query'] = array(
array(
'key' => 'customer_age',
'value' => 80,
'compare' => '>='
)
);
$query = new OrdersTableQuery( $args );
$this->assertEqualsCanonicalizing( array( $order_ids[1] ), $query->orders );
}
/**
* Test that props set by datastores can be set and get by using any of metadata, object props or from data store setters.
* Ideally, this should be possible only from getters and setters for objects, but for backward compatibility, earlier ways are also supported.
@ -1267,4 +1481,5 @@ class OrdersTableDataStoreTests extends WC_Unit_Test_Case {
$this->assertEquals( $value, $order->{"get_$prop_name"}(), "Prop $prop_name was not set correctly." );
}
}
}