diff --git a/plugins/woocommerce-admin/docs/data.md b/plugins/woocommerce-admin/docs/data.md index 59b2419c167..dc8120647b9 100644 --- a/plugins/woocommerce-admin/docs/data.md +++ b/plugins/woocommerce-admin/docs/data.md @@ -1,4 +1,84 @@ Data ==== -TBA, this will document our data implementation. +WooCommerce Admin data stores implement the [`SqlQuery` class](https://github.com/woocommerce/woocommerce-admin/blob/master/src/API/Reports/SqlQuery.php). + +### SqlQuery Class + +The `SqlQuery` class is a SQL Query statement object. Its properties consist of + +- A `context` string identifying the context of the query. +- SQL clause (`type`) string arrays used to construct the SQL statement: + - `select` + - `from` + - `right_join` + - `join` + - `left_join` + - `where` + - `where_time` + - `group_by` + - `having` + - `order_by` + - `limit` + +### Reports Data Stores + +The base DataStore `Automattic\WooCommerce\Admin\API\Reports\DataStore` extends the `SqlQuery` class. The implementation data store classes use the following `SqlQuery` instances: + +| Data Store | Context | Class Query | Sub Query | Interval Query | Total Query | +| ---------- | ------- | ----------- | --------- | -------------- | ----------- | +| Categories | categories | Yes | Yes | - | - | +| Coupons | coupons | Yes | Yes | - | - | +| Coupon Stats | coupon_stats | Yes | - | Yes | Yes | +| Customers | customers | Yes | Yes | - | - | +| Customer Stats | customer_stats | Yes | - | Yes | Yes | +| Downloads | downloads | Yes | Yes | - | - | +| Download Stats | download_stats | Yes | - | Yes | Yes | +| Orders | orders | Yes | Yes | - | - | +| Order Stats | order_stats | Yes | - | Yes | Yes | +| Products | products | Yes | Yes | - | - | +| Product Stats | product_stats | Yes | - | Yes | Yes | +| Taxes | taxes | Yes | Yes | - | - | +| Tax Stats | tax_stats | Yes | - | Yes | Yes | +| Variations | variations | Yes | Yes | - | - | + +Query contexts are named as follows: + +- Class Query = Class Context +- Sub Query = Class Context + `_subquery` +- Interval Query = Class Context + `_interval` +- Total Query = Class Context + `_total` + +### Filters + +When getting the full statement the clause arrays are passed through two filters where `$context` is the query object context and `$type` is: + +- `select` +- `from` +- `join` = `right_join` + `join` + `left_join` +- `where` = `where` + `where_time` +- `group_by` +- `having` +- `order_by` +- `limit` + +The filters are: + +- `apply_filters( "wc_admin_clauses_{$type}", $clauses, $context );` +- `apply_filters( "wc_admin_clauses_{$type}_{$context}", $clauses );` + +Example usage + +``` +add_filter( 'wc_admin_clauses_product_stats_select_total', 'my_custom_product_stats' ); +/** + * Add sample data to product stats totals. + * + * @param array $clauses array of SELECT clauses. + * @return array + */ +function my_custom_product_stats( $clauses ) { + $clauses[] = ', SUM( sample_column ) as sample_total'; + return $clauses; +} +``` \ No newline at end of file diff --git a/plugins/woocommerce-admin/src/API/Reports/Categories/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Categories/DataStore.php index 41f6d7cfc34..23cb3dc05dc 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Categories/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Categories/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Categories\DataStore. @@ -23,7 +24,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_product_lookup'; + protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. @@ -60,102 +61,83 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'items_sold' => 'SUM(product_qty) as items_sold', - 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', - 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', - 'products_count' => 'COUNT(DISTINCT product_id) as products_count', - ); + protected $context = 'categories'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['products_count'] = str_replace( 'product_id', $table_name . '.product_id', $this->report_columns['products_count'] ); - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'items_sold' => 'SUM(product_qty) as items_sold', + 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', + 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", + 'products_count' => "COUNT(DISTINCT {$table_name}.product_id) as products_count", + ); } /** * Return the database query with parameters used for Categories report: time span and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_product_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); + $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); - // join wp_order_product_lookup_table with relationships and taxonomies. - $sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id"; - $sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->wc_category_lookup}.category_id"; + // join wp_order_product_lookup_table with relationships and taxonomies + // @todo How to handle custom product tables? + $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id" ); + $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->wc_category_lookup}.category_id" ); $included_categories = $this->get_included_categories( $query_args ); if ( $included_categories ) { - $sql_query_params['where_clause'] .= " AND {$wpdb->wc_category_lookup}.category_tree_id IN ({$included_categories})"; + $this->subquery->add_sql_clause( 'where', "AND {$wpdb->wc_category_lookup}.category_tree_id IN ({$included_categories})" ); // Limit is left out here so that the grouping in code by PHP can be applied correctly. // This also needs to be put after the term_taxonomy JOIN so that we can match the correct term name. - $sql_query_params = $this->get_order_by_params( $query_args, $sql_query_params, 'outer_from_clause', 'default_results.category_id' ); + $this->get_order_by_params( $query_args, 'outer', 'default_results.category_id' ); } else { - $sql_query_params = $this->get_order_by_params( $query_args, $sql_query_params, 'from_clause', "{$wpdb->wc_category_lookup}.category_tree_id" ); + $this->get_order_by_params( $query_args, 'inner', "{$wpdb->wc_category_lookup}.category_tree_id" ); } // @todo Only products in the category C or orders with products from category C (and, possibly others?). $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { - $sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; + $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } - $order_status_filter = $this->get_status_subquery( $query_args ); - if ( $order_status_filter ) { - $sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; - } - - $sql_query_params['where_clause'] .= " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; - - return $sql_query_params; + $this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery ); + $this->subquery->add_sql_clause( 'where', "AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL" ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @param array $sql_query Current SQL query array. - * @param string $from_arg Name of the FROM sql param. + * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. - * @return array */ - protected function get_order_by_params( $query_args, $sql_query, $from_arg, $id_cell ) { + protected function get_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; - $lookup_table = $wpdb->prefix . self::TABLE_NAME; + $lookup_table = self::get_db_table_name(); + $order_by_clause = $this->add_order_by_clause( $query_args, $this ); + $this->add_orderby_order_clause( $query_args, $this ); - $sql_query['order_by_clause'] = ''; - if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); + if ( false !== strpos( $order_by_clause, '_terms' ) ) { + $join = "JOIN {$wpdb->terms} AS _terms ON {$id_cell} = _terms.term_id"; + if ( 'inner' === $from_arg ) { + $this->subquery->add_sql_clause( 'join', $join ); + } else { + $this->add_sql_clause( 'join', $join ); + } } - - $sql_query['outer_from_clause'] = ''; - if ( false !== strpos( $sql_query['order_by_clause'], '_terms' ) ) { - $sql_query[ $from_arg ] .= " JOIN {$wpdb->prefix}terms AS _terms ON {$id_cell} = _terms.term_id"; - } - - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; - } else { - $sql_query['order_by_clause'] .= ' DESC'; - } - - return $sql_query; } /** @@ -187,17 +169,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return array(); } - /** - * Returns comma separated ids of included categories, based on query arguments from the user. - * - * @param array $query_args Parameters supplied by the user. - * @return string - */ - protected function get_included_categories( $query_args ) { - $included_categories = $this->get_included_categories_array( $query_args ); - return implode( ',', $included_categories ); - } - /** * Returns the page of data according to page number and items per page. * @@ -236,7 +207,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -261,6 +232,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -268,45 +241,31 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->get_sql_query_params( $query_args ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); $included_categories = $this->get_included_categories_array( $query_args ); + $this->get_sql_query_params( $query_args ); if ( count( $included_categories ) > 0 ) { - $fields = $this->get_fields( $query_args ); - $join_selections = $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ); - $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); + $fields = $this->get_fields( $query_args ); + $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); - $prefix = "SELECT {$join_selections} FROM ("; - $suffix = ") AS {$table_name}"; - $right_join = "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.category_id = {$table_name}.category_id"; + $this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.category_id = {$table_name}.category_id" + ); + + $categories_query = $this->get_query_statement(); } else { - $prefix = ''; - $suffix = ''; - $right_join = ''; + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $categories_query = $this->subquery->get_query_statement(); } - $categories_data = $wpdb->get_results( - "${prefix} - SELECT - {$wpdb->wc_category_lookup}.category_tree_id as category_id, - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$wpdb->wc_category_lookup}.category_tree_id - {$suffix} - {$right_join} - {$sql_query_params['outer_from_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - ", + $categories_query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -335,4 +294,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $data; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + global $wpdb; + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'select', "{$wpdb->wc_category_lookup}.category_tree_id as category_id," ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + $this->subquery->add_sql_clause( 'group_by', "{$wpdb->wc_category_lookup}.category_tree_id" ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php index 4a49b22a373..0b77a4d85eb 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Coupons/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** @@ -24,7 +25,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_coupon_lookup'; + protected static $table_name = 'wc_order_coupon_lookup'; /** * Cache identifier. @@ -45,24 +46,22 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query and their mapping to SQL code. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'coupon_id' => 'coupon_id', - 'amount' => 'SUM(discount_amount) as amount', - 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', - ); + protected $context = 'coupons'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'coupon_id' => 'coupon_id', + 'amount' => 'SUM(discount_amount) as amount', + 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", + ); } /** @@ -86,78 +85,55 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return array(); } - /** - * Returns comma separated ids of included coupons, based on query arguments from the user. - * - * @param array $query_args Parameters supplied by the user. - * @return string - */ - protected function get_included_coupons( $query_args ) { - $included_coupons = $this->get_included_coupons_array( $query_args ); - return implode( ',', $included_coupons ); - } - /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_coupon_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_coupon_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_coupon_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_coupon_lookup_table ); + $this->get_limit_sql_params( $query_args ); - $included_coupons = $this->get_included_coupons( $query_args ); + $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { - $sql_query_params['where_clause'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; + $this->subquery->add_sql_clause( 'where', "AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})" ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_params( $query_args, 'outer_from_clause', 'default_results.coupon_id' ) ); + $this->get_order_by_params( $query_args, 'outer', 'default_results.coupon_id' ); } else { - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_params( $query_args, 'from_clause', "{$order_coupon_lookup_table}.coupon_id" ) ); + $this->get_order_by_params( $query_args, 'inner', "{$order_coupon_lookup_table}.coupon_id" ); } - $order_status_filter = $this->get_status_subquery( $query_args ); - if ( $order_status_filter ) { - $sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; - } - - return $sql_query_params; + $this->add_order_status_clause( $query_args, $order_coupon_lookup_table, $this->subquery ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @param string $from_arg Name of the FROM sql param. + * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. - * @return array */ protected function get_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; - $lookup_table = $wpdb->prefix . self::TABLE_NAME; - $sql_query = array(); - $sql_query['from_clause'] = ''; - $sql_query['outer_from_clause'] = ''; - $sql_query['order_by_clause'] = ''; - if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); - } + $lookup_table = self::get_db_table_name(); + $order_by_clause = $this->add_order_by_clause( $query_args, $this ); + $join = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell} = _coupons.ID"; + $this->add_orderby_order_clause( $query_args, $this ); - if ( false !== strpos( $sql_query['order_by_clause'], '_coupons' ) ) { - $sql_query[ $from_arg ] .= " JOIN {$wpdb->prefix}posts AS _coupons ON {$id_cell} = _coupons.ID"; - } - - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; + if ( 'inner' === $from_arg ) { + $this->subquery->clear_sql_clause( 'join' ); + if ( false !== strpos( $order_by_clause, '_coupons' ) ) { + $this->subquery->add_sql_clause( 'join', $join ); + } } else { - $sql_query['order_by_clause'] .= ' DESC'; + $this->clear_sql_clause( 'join' ); + if ( false !== strpos( $order_by_clause, '_coupons' ) ) { + $this->add_sql_clause( 'join', $join ); + } } - - return $sql_query; } /** @@ -235,7 +211,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -260,6 +236,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -268,69 +246,51 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->get_sql_query_params( $query_args ); $included_coupons = $this->get_included_coupons_array( $query_args ); + $limit_params = $this->get_limit_params( $query_args ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->get_sql_query_params( $query_args ); if ( count( $included_coupons ) > 0 ) { $total_results = count( $included_coupons ); - $total_pages = (int) ceil( $total_results / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $total_results / $limit_params['per_page'] ); - $fields = $this->get_fields( $query_args ); - $join_selections = $this->format_join_selections( $fields, array( 'coupon_id' ) ); - $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); + $fields = $this->get_fields( $query_args ); + $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); - $prefix = "SELECT {$join_selections} FROM ("; - $suffix = ") AS {$table_name}"; - $right_join = "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.coupon_id = {$table_name}.coupon_id"; + $this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.coupon_id = {$table_name}.coupon_id" + ); + + $coupons_query = $this->get_query_statement(); } else { + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $coupons_query = $this->subquery->get_query_statement(); + + $this->subquery->clear_sql_clause( array( 'select', 'order_by' ) ); + $this->subquery->add_sql_clause( 'select', 'coupon_id' ); + $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - coupon_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - coupon_id - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - - $prefix = ''; - $suffix = ''; - $right_join = ''; } $coupon_data = $wpdb->get_results( - "${prefix} - SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - coupon_id - {$suffix} - {$right_join} - {$sql_query_params['outer_from_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $coupons_query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -388,7 +348,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } $result = $wpdb->replace( - $wpdb->prefix . self::TABLE_NAME, + self::get_db_table_name(), array( 'order_id' => $order_id, 'coupon_id' => $coupon_id, @@ -426,15 +386,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function sync_on_order_delete( $order_id ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - - $wpdb->query( - $wpdb->prepare( - "DELETE FROM ${table_name} WHERE order_id = %d", - $order_id - ) - ); - + $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when coupon's reports are removed from database. * @@ -460,7 +412,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } $wpdb->delete( - $wpdb->prefix . self::TABLE_NAME, + self::get_db_table_name(), array( 'coupon_id' => $post_id ) ); @@ -474,15 +426,25 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @param array $args Array of args to filter the query by. Supports `include`. * @return array Array of results. */ - public static function get_coupons( $args ) { + public function get_coupons( $args ) { global $wpdb; - $query = "SELECT ID, post_title FROM {$wpdb->prefix}posts WHERE post_type='shop_coupon'"; + $query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type='shop_coupon'"; - if ( ! empty( $args['include'] ) ) { - $included_coupons = implode( ',', $args['include'] ); - $query .= " AND ID IN ({$included_coupons})"; + $included_coupons = $this->get_included_coupons( $args, 'include' ); + if ( ! empty( $included_coupons ) ) { + $query .= " AND ID IN ({$included_coupons})"; } return $wpdb->get_results( $query ); // WPCS: cache ok, DB call ok, unprepared SQL ok. } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + $this->subquery->add_sql_clause( 'group_by', 'coupon_id' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Coupons/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Coupons/Stats/DataStore.php index 60c4b9cdab9..c0037be6331 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Coupons/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Coupons/Stats/DataStore.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Coupons\Stats\DataStore. @@ -36,11 +37,14 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { * * @var array */ - protected $report_columns = array( - 'amount' => 'SUM(discount_amount) as amount', - 'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count', - 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', - ); + protected $report_columns; + + /** + * Data store context used to pass to filters. + * + * @var string + */ + protected $context = 'coupon_stats'; /** * Cache identifier. @@ -50,48 +54,56 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { protected $cache_key = 'coupons_stats'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'amount' => 'SUM(discount_amount) as amount', + 'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count', + 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", + ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @param array $totals_params SQL parameters for the totals query. - * @param array $intervals_params SQL parameters for the intervals query. */ - protected function update_sql_query_params( $query_args, &$totals_params, &$intervals_params ) { + protected function update_sql_query_params( $query_args ) { global $wpdb; - $coupons_where_clause = ''; - $coupons_from_clause = ''; + $clauses = array( + 'where' => '', + 'join' => '', + ); - $order_coupon_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_coupon_lookup_table = self::get_db_table_name(); - $included_coupons = $this->get_included_coupons( $query_args ); + $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { - $coupons_where_clause .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; + $clauses['where'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { - $coupons_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $coupons_where_clause .= " AND ( {$order_status_filter} )"; + $clauses['join'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; + $clauses['where'] .= " AND ( {$order_status_filter} )"; } - $totals_params = array_merge( $totals_params, $this->get_time_period_sql_params( $query_args, $order_coupon_lookup_table ) ); - $totals_params['where_clause'] .= $coupons_where_clause; - $totals_params['from_clause'] .= $coupons_from_clause; + $this->get_time_period_sql_params( $query_args, $order_coupon_lookup_table ); + $this->get_intervals_sql_params( $query_args, $order_coupon_lookup_table ); + $clauses['where_time'] = $this->get_sql_clause( 'where_time' ); - $intervals_params = array_merge( $intervals_params, $this->get_intervals_sql_params( $query_args, $order_coupon_lookup_table ) ); - $intervals_params['where_clause'] .= $coupons_where_clause; - $intervals_params['from_clause'] .= $coupons_from_clause; + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) ); + $this->interval_query->add_sql_clause( 'select', 'AS time_interval' ); + + foreach ( array( 'join', 'where_time', 'where' ) as $clause ) { + $this->interval_query->add_sql_clause( $clause, $clauses[ $clause ] ); + $this->total_query->add_sql_clause( $clause, $clauses[ $clause ] ); + } } /** @@ -104,7 +116,7 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -129,6 +141,8 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -139,73 +153,57 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { $selections = $this->selected_columns( $query_args ); $totals_query = array(); $intervals_query = array(); + $limit_params = $this->get_limit_sql_params( $query_args ); $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); $db_intervals = $wpdb->get_col( - "SELECT - {$intervals_query['select_clause']} AS time_interval - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval" + $this->interval_query->get_query_statement() ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $db_interval_count = count( $db_intervals ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] ); + $total_pages = (int) ceil( $expected_interval_count / $limit_params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } + $this->total_query->add_sql_clause( 'select', $selections ); $totals = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$totals_query['from_clause']} - WHERE - 1=1 - {$totals_query['where_time_clause']} - {$totals_query['where_clause']}", + $this->total_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. if ( null === $totals ) { return $data; } + + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); // Intervals. - $this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { - $selections = ', ' . $selections; + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( - "SELECT - MAX({$table_name}.date_created) AS datetime_anchor, - {$intervals_query['select_clause']} AS time_interval - {$selections} - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval - ORDER BY - {$intervals_query['order_by_clause']} - {$intervals_query['limit']}", + $this->interval_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -221,10 +219,10 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { 'page_no' => (int) $query_args['page'], ); - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } @@ -236,4 +234,18 @@ class DataStore extends CouponsDataStore implements DataStoreInterface { return $data; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Customers/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Customers/DataStore.php index 4be0a5b056b..71a5b67fe38 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Customers/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Customers/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** @@ -24,7 +25,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_customer_lookup'; + protected static $table_name = 'wc_customer_lookup'; /** * Cache identifier. @@ -47,36 +48,35 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query and their mapping to SQL code. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'id' => 'customer_id as id', - 'user_id' => 'user_id', - 'username' => 'username', - 'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @todo What does this mean for RTL? - 'email' => 'email', - 'country' => 'country', - 'city' => 'city', - 'state' => 'state', - 'postcode' => 'postcode', - 'date_registered' => 'date_registered', - 'date_last_active' => 'IF( date_last_active <= "0000-00-00 00:00:00", NULL, date_last_active ) AS date_last_active', - 'orders_count' => 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) as orders_count', - 'total_spend' => 'SUM( gross_total ) as total_spend', - 'avg_order_value' => '( SUM( gross_total ) / COUNT( order_id ) ) as avg_order_value', - ); + protected $context = 'customers'; /** - * Constructor. + * Assign report columns once full table name has been assigned. */ - public function __construct() { + protected function assign_report_columns() { global $wpdb; - - // Initialize some report columns that need disambiguation. - $this->report_columns['id'] = $wpdb->prefix . self::TABLE_NAME . '.customer_id as id'; - $this->report_columns['date_last_order'] = "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order"; + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'id' => "{$table_name}.customer_id as id", + 'user_id' => 'user_id', + 'username' => 'username', + 'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @todo What does this mean for RTL? + 'email' => 'email', + 'country' => 'country', + 'city' => 'city', + 'state' => 'state', + 'postcode' => 'postcode', + 'date_registered' => 'date_registered', + 'date_last_active' => 'IF( date_last_active <= "0000-00-00 00:00:00", NULL, date_last_active ) AS date_last_active', + 'date_last_order' => "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order", + 'orders_count' => 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) as orders_count', + 'total_spend' => 'SUM( gross_total ) as total_spend', + 'avg_order_value' => '( SUM( gross_total ) / COUNT( order_id ) ) as avg_order_value', + ); } /** @@ -115,43 +115,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $order_by; } - /** - * Fills ORDER BY clause of SQL request based on user supplied parameters. - * - * @param array $query_args Parameters supplied by the user. - * @return array - */ - protected function get_order_by_sql_params( $query_args ) { - $sql_query['order_by_clause'] = ''; - - if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); - } - - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; - } else { - $sql_query['order_by_clause'] .= ' DESC'; - } - - return $sql_query; - } - /** * Fills WHERE clause of SQL request with date-related constraints. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. - * @return array */ protected function get_time_period_sql_params( $query_args, $table_name ) { global $wpdb; - $sql_query = array( - 'where_time_clause' => '', - 'where_clause' => '', - 'having_clause' => '', - ); + $this->clear_sql_clause( array( 'where', 'where_time', 'having' ) ); $date_param_mapping = array( 'registered' => array( 'clause' => 'where', @@ -202,31 +175,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } if ( $where_time_clauses ) { - $sql_query['where_time_clause'] = ' AND ' . implode( " {$match_operator} ", $where_time_clauses ); + $this->subquery->add_sql_clause( 'where_time', 'AND ' . implode( " {$match_operator} ", $where_time_clauses ) ); } if ( $having_time_clauses ) { - $sql_query['having_clause'] = ' AND ' . implode( " {$match_operator} ", $having_time_clauses ); + $this->subquery->add_sql_clause( 'having', 'AND ' . implode( " {$match_operator} ", $having_time_clauses ) ); } - - return $sql_query; } /** * Updates the database query with parameters used for Customers report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $customer_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $customer_lookup_table = self::get_db_table_name(); $order_stats_table_name = $wpdb->prefix . 'wc_order_stats'; - $sql_query_params = $this->get_time_period_sql_params( $query_args, $customer_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); - $sql_query_params['from_clause'] = " LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id"; + $this->get_time_period_sql_params( $query_args, $customer_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); + $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id" ); $match_operator = $this->get_match_operator( $query_args ); $where_clauses = array(); @@ -279,7 +249,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { // Allow a list of customer IDs to be specified. if ( ! empty( $query_args['customers'] ) ) { - $included_customers = implode( ',', array_map( 'intval', $query_args['customers'] ) ); + $included_customers = $this->get_filtered_ids( $query_args, 'customers' ); $where_clauses[] = "{$customer_lookup_table}.customer_id IN ({$included_customers})"; } @@ -324,21 +294,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } if ( $where_clauses ) { - $preceding_match = empty( $sql_query_params['where_time_clause'] ) ? ' AND ' : " {$match_operator} "; - $sql_query_params['where_clause'] = $preceding_match . implode( " {$match_operator} ", $where_clauses ); + $preceding_match = empty( $this->get_sql_clause( 'where_time' ) ) ? ' AND ' : " {$match_operator} "; + $this->subquery->add_sql_clause( 'where', $preceding_match . implode( " {$match_operator} ", $where_clauses ) ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { - $sql_query_params['from_clause'] .= " AND ( {$order_status_filter} )"; + $this->subquery->add_sql_clause( 'left_join', "AND ( {$order_status_filter} )" ); } if ( $having_clauses ) { - $preceding_match = empty( $sql_query_params['having_clause'] ) ? ' AND ' : " {$match_operator} "; - $sql_query_params['having_clause'] .= $preceding_match . implode( " {$match_operator} ", $having_clauses ); + $preceding_match = empty( $this->get_sql_clause( 'having' ) ) ? ' AND ' : " {$match_operator} "; + $this->subquery->add_sql_clause( 'having', $preceding_match . implode( " {$match_operator} ", $having_clauses ) ); } - - return $sql_query_params; } /** @@ -350,7 +318,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $customers_table_name = $wpdb->prefix . self::TABLE_NAME; + $customers_table_name = self::get_db_table_name(); $order_stats_table_name = $wpdb->prefix . 'wc_order_stats'; // These defaults are only partially applied when used via REST API, as that has its own defaults. @@ -374,6 +342,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -386,46 +356,23 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT {$customers_table_name}.customer_id - FROM - {$customers_table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$customers_table_name}.customer_id - HAVING - 1=1 - {$sql_query_params['having_clause']} + {$this->subquery->get_query_statement()} ) as tt " ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $params = $this->get_limit_params( $query_args ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $customer_data = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$customers_table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$customers_table_name}.customer_id - HAVING - 1=1 - {$sql_query_params['having_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $this->subquery->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -542,7 +489,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $format[] = '%s'; } - $result = $wpdb->insert( $wpdb->prefix . self::TABLE_NAME, $data, $format ); + $result = $wpdb->insert( self::get_db_table_name(), $data, $format ); $customer_id = $wpdb->insert_id; /** @@ -564,7 +511,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function get_guest_id_by_email( $email ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$table_name} WHERE email = %s AND user_id IS NULL LIMIT 1", @@ -584,7 +531,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function get_customer_id_by_user_id( $user_id ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$table_name} WHERE user_id = %d LIMIT 1", @@ -680,7 +627,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $format[] = '%d'; } - $results = $wpdb->replace( $wpdb->prefix . self::TABLE_NAME, $data, $format ); + $results = $wpdb->replace( self::get_db_table_name(), $data, $format ); /** * Fires when customser's reports are updated. @@ -723,14 +670,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function delete_customer( $customer_id ) { global $wpdb; $customer_id = (int) $customer_id; - $table_name = $wpdb->prefix . self::TABLE_NAME; - $wpdb->query( - $wpdb->prepare( - "DELETE FROM ${table_name} WHERE customer_id = %d", - $customer_id - ) - ); + $wpdb->delete( self::get_db_table_name(), array( 'customer_id' => $customer_id ) ); /** * Fires when a customer is deleted. @@ -739,4 +680,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { */ do_action( 'woocommerce_reports_delete_customer', $customer_id ); } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $table_name = self::get_db_table_name(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'from', $table_name ); + $this->subquery->add_sql_clause( 'select', "{$table_name}.customer_id" ); + $this->subquery->add_sql_clause( 'group_by', "{$table_name}.customer_id" ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Customers/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Customers/Stats/DataStore.php index 1da25d48309..5b830b7bd5b 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Customers/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Customers/Stats/DataStore.php @@ -28,18 +28,6 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { 'avg_avg_order_value' => 'floatval', ); - /** - * SQL columns to select in the db query and their mapping to SQL code. - * - * @var array - */ - protected $report_columns = array( - 'customers_count' => 'COUNT( * ) as customers_count', - 'avg_orders_count' => 'AVG( orders_count ) as avg_orders_count', - 'avg_total_spend' => 'AVG( total_spend ) as avg_total_spend', - 'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value', - ); - /** * Cache identifier. * @@ -48,10 +36,22 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { protected $cache_key = 'customers_stats'; /** - * Constructor. + * Data store context used to pass to filters. + * + * @var string */ - public function __construct() { - // This space intentionally left blank (to avoid parent constructor). + protected $context = 'customer_stats'; + + /** + * Assign report columns once full table name has been assigned. + */ + protected function assign_report_columns() { + $this->report_columns = array( + 'customers_count' => 'COUNT( * ) as customers_count', + 'avg_orders_count' => 'AVG( orders_count ) as avg_orders_count', + 'avg_total_spend' => 'AVG( total_spend ) as avg_total_spend', + 'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value', + ); } /** @@ -63,7 +63,7 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $customers_table_name = $wpdb->prefix . self::TABLE_NAME; + $customers_table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -84,6 +84,8 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'customers_count' => 0, 'avg_orders_count' => 0, @@ -93,32 +95,23 @@ class DataStore extends CustomersDataStore implements DataStoreInterface { $selections = $this->selected_columns( $query_args ); $sql_query_params = $this->get_sql_query_params( $query_args ); + // Clear SQL clauses set for parent class queries that are different here. + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', 'SUM( gross_total ) AS total_spend,' ); + $this->subquery->add_sql_clause( + 'select', + 'CASE WHEN COUNT( order_id ) = 0 THEN NULL ELSE COUNT( order_id ) END AS orders_count,' + ); + $this->subquery->add_sql_clause( + 'select', + 'CASE WHEN COUNT( order_id ) = 0 THEN NULL ELSE SUM( gross_total ) / COUNT( order_id ) END AS avg_order_value' + ); + $this->clear_sql_clause( array( 'order_by', 'limit' ) ); + $this->add_sql_clause( 'select', $selections ); + $this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" ); $report_data = $wpdb->get_results( - "SELECT {$selections} FROM - ( - SELECT - ( - CASE WHEN COUNT( order_id ) = 0 - THEN NULL - ELSE COUNT( order_id ) - END - ) as orders_count, - SUM( gross_total ) as total_spend, - ( SUM( gross_total ) / COUNT( order_id ) ) as avg_order_value - FROM - {$customers_table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$customers_table_name}.customer_id - HAVING - 1=1 - {$sql_query_params['having_clause']} - ) as tt", + $this->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. diff --git a/plugins/woocommerce-admin/src/API/Reports/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/DataStore.php index 1063feb3ac5..53bd92a49bc 100644 --- a/plugins/woocommerce-admin/src/API/Reports/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/DataStore.php @@ -16,7 +16,7 @@ use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * Admin\API\Reports\DataStore: Common parent for custom report data stores. */ -class DataStore { +class DataStore extends SqlQuery { /** * Cache group for the reports. @@ -37,7 +37,7 @@ class DataStore { * * @var string */ - const TABLE_NAME = ''; + protected static $table_name = ''; /** * Mapping columns to data type to return correct response types. @@ -46,13 +46,6 @@ class DataStore { */ protected $column_types = array(); - /** - * SQL columns to select in the db query. - * - * @var array - */ - protected $report_columns = array(); - // @todo This does not really belong here, maybe factor out the comparison as separate class? /** * Order by property, used in the cmp function. @@ -66,6 +59,65 @@ class DataStore { * @var string */ private $order = ''; + /** + * Query limit parameters. + * + * @var array + */ + private $limit_parameters = array(); + /** + * Data store context used to pass to filters. + * + * @var string + */ + protected $context = 'reports'; + + /** + * Subquery object for query nesting. + * + * @var SqlQuery + */ + protected $subquery; + + /** + * Totals query object. + * + * @var SqlQuery + */ + protected $total_query; + + /** + * Intervals query object. + * + * @var SqlQuery + */ + protected $interval_query; + + /** + * Class constructor. + */ + public function __construct() { + self::set_db_table_name(); + $this->assign_report_columns(); + } + + /** + * Get table name from database class. + */ + public static function get_db_table_name() { + global $wpdb; + return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name; + } + + /** + * Set table name from database class. + */ + protected static function set_db_table_name() { + global $wpdb; + if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) { + $wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name; + } + } /** * Returns string to be used as cache key for the data. @@ -328,16 +380,17 @@ class DataStore { * If there are less records in the database than time intervals, then we need to remap offset in SQL query * to fetch correct records. * - * @param array $intervals_query Array with clauses for the Intervals SQL query. * @param array $query_args Query arguments. * @param int $db_interval_count Database interval count. * @param int $expected_interval_count Expected interval count on the output. * @param string $table_name Name of the db table relevant for the date constraint. */ - protected function update_intervals_sql_params( &$intervals_query, &$query_args, $db_interval_count, $expected_interval_count, $table_name ) { + protected function update_intervals_sql_params( &$query_args, $db_interval_count, $expected_interval_count, $table_name ) { if ( $db_interval_count === $expected_interval_count ) { return; } + + $params = $this->get_limit_params( $query_args ); $local_tz = new \DateTimeZone( wc_timezone_string() ); if ( 'date' === strtolower( $query_args['orderby'] ) ) { // page X in request translates to slightly different dates in the db, in case some @@ -347,7 +400,7 @@ class DataStore { if ( 'asc' === strtolower( $query_args['order'] ) ) { // ORDER BY date ASC. $new_start_date = $query_args['after']; - $intervals_to_skip = ( $query_args['page'] - 1 ) * $intervals_query['per_page']; + $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $latest_end_date = $query_args['before']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_start_date > $latest_end_date ) { @@ -360,7 +413,7 @@ class DataStore { } $new_end_date = clone $new_start_date; - for ( $i = 0; $i < $intervals_query['per_page']; $i++ ) { + for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_end_date > $latest_end_date ) { break; } @@ -378,7 +431,7 @@ class DataStore { } else { // ORDER BY date DESC. $new_end_date = $query_args['before']; - $intervals_to_skip = ( $query_args['page'] - 1 ) * $intervals_query['per_page']; + $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $earliest_start_date = $query_args['after']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_end_date < $earliest_start_date ) { @@ -391,7 +444,7 @@ class DataStore { } $new_start_date = clone $new_end_date; - for ( $i = 0; $i < $intervals_query['per_page']; $i++ ) { + for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_start_date < $earliest_start_date ) { break; } @@ -413,21 +466,24 @@ class DataStore { $query_args['adj_before'] = $new_end_date; $adj_after = $new_start_date->format( TimeInterval::$sql_datetime_format ); $adj_before = $new_end_date->format( TimeInterval::$sql_datetime_format ); - $intervals_query['where_time_clause'] = ''; - $intervals_query['where_time_clause'] .= " AND {$table_name}.date_created <= '$adj_before'"; - $intervals_query['where_time_clause'] .= " AND {$table_name}.date_created >= '$adj_after'"; - $intervals_query['limit'] = 'LIMIT 0,' . $intervals_query['per_page']; + $this->interval_query->clear_sql_clause( array( 'where_time', 'limit' ) ); + $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.date_created <= '$adj_before'" ); + $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.date_created >= '$adj_after'" ); + $this->clear_sql_clause( 'limit' ); + $this->add_sql_clause( 'limit', 'LIMIT 0,' . $params['per_page'] ); } else { if ( 'asc' === $query_args['order'] ) { - $offset = ( ( $query_args['page'] - 1 ) * $intervals_query['per_page'] ) - ( $expected_interval_count - $db_interval_count ); + $offset = ( ( $query_args['page'] - 1 ) * $params['per_page'] ) - ( $expected_interval_count - $db_interval_count ); $offset = $offset < 0 ? 0 : $offset; - $count = $query_args['page'] * $intervals_query['per_page'] - ( $expected_interval_count - $db_interval_count ); + $count = $query_args['page'] * $params['per_page'] - ( $expected_interval_count - $db_interval_count ); if ( $count < 0 ) { $count = 0; - } elseif ( $count > $intervals_query['per_page'] ) { - $count = $intervals_query['per_page']; + } elseif ( $count > $params['per_page'] ) { + $count = $params['per_page']; } - $intervals_query['limit'] = 'LIMIT ' . $offset . ',' . $count; + + $this->clear_sql_clause( 'limit' ); + $this->add_sql_clause( 'limit', 'LIMIT ' . $offset . ',' . $count ); } // Otherwise no change in limit clause. // @todo - Do this without modifying $query_args? @@ -592,14 +648,12 @@ class DataStore { * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. - * @return array */ protected function get_time_period_sql_params( $query_args, $table_name ) { - $sql_query = array( - 'from_clause' => '', - 'where_time_clause' => '', - 'where_clause' => '', - ); + $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); + if ( isset( $this->subquery ) ) { + $this->subquery->clear_sql_clause( 'where_time' ); + } if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) { if ( is_a( $query_args['before'], 'WC_DateTime' ) ) { @@ -607,8 +661,11 @@ class DataStore { } else { $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); } - $sql_query['where_time_clause'] .= " AND {$table_name}.date_created <= '$datetime_str'"; - + if ( isset( $this->subquery ) ) { + $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.date_created <= '$datetime_str'" ); + } else { + $this->add_sql_clause( 'where_time', "AND {$table_name}.date_created <= '$datetime_str'" ); + } } if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) { @@ -617,10 +674,12 @@ class DataStore { } else { $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); } - $sql_query['where_time_clause'] .= " AND {$table_name}.date_created >= '$datetime_str'"; + if ( isset( $this->subquery ) ) { + $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.date_created >= '$datetime_str'" ); + } else { + $this->add_sql_clause( 'where_time', "AND {$table_name}.date_created >= '$datetime_str'" ); + } } - - return $sql_query; } /** @@ -630,18 +689,32 @@ class DataStore { * @return array */ protected function get_limit_sql_params( $query_args ) { - $sql_query['per_page'] = get_option( 'posts_per_page' ); + $params = $this->get_limit_params( $query_args ); + + $this->clear_sql_clause( 'limit' ); + $this->add_sql_clause( 'limit', "LIMIT {$params['offset']}, {$params['per_page']}" ); + return $params; + } + + /** + * Fills LIMIT parameters of SQL request based on user supplied parameters. + * + * @param array $query_args Parameters supplied by the user. + * @return array + */ + protected function get_limit_params( $query_args = array() ) { if ( isset( $query_args['per_page'] ) && is_numeric( $query_args['per_page'] ) ) { - $sql_query['per_page'] = (int) $query_args['per_page']; + $this->limit_parameters['per_page'] = (int) $query_args['per_page']; + } else { + $this->limit_parameters['per_page'] = get_option( 'posts_per_page' ); } - $sql_query['offset'] = 0; + $this->limit_parameters['offset'] = 0; if ( isset( $query_args['page'] ) ) { - $sql_query['offset'] = ( (int) $query_args['page'] - 1 ) * $sql_query['per_page']; + $this->limit_parameters['offset'] = ( (int) $query_args['page'] - 1 ) * $this->limit_parameters['per_page']; } - $sql_query['limit'] = "LIMIT {$sql_query['offset']}, {$sql_query['per_page']}"; - return $sql_query; + return $this->limit_parameters; } /** @@ -703,21 +776,17 @@ class DataStore { * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @return array */ protected function get_order_by_sql_params( $query_args ) { - $sql_query['order_by_clause'] = ''; if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); - } - - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; + $order_by_clause = $this->normalize_order_by( $query_args['orderby'] ); } else { - $sql_query['order_by_clause'] .= ' DESC'; + $order_by_clause = ''; } - return $sql_query; + $this->clear_sql_clause( 'order_by' ); + $this->add_sql_clause( 'order_by', $order_by_clause ); + $this->add_orderby_order_clause( $query_args, $this ); } /** @@ -725,27 +794,17 @@ class DataStore { * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. - * @return array */ protected function get_intervals_sql_params( $query_args, $table_name ) { - $intervals_query = array( - 'from_clause' => '', - 'where_time_clause' => '', - 'where_clause' => '', - ); + $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); - $intervals_query = array_merge( $intervals_query, $this->get_time_period_sql_params( $query_args, $table_name ) ); + $this->get_time_period_sql_params( $query_args, $table_name ); if ( isset( $query_args['interval'] ) && '' !== $query_args['interval'] ) { $interval = $query_args['interval']; - $intervals_query['select_clause'] = TimeInterval::db_datetime_format( $interval, $table_name ); + $this->clear_sql_clause( 'select' ); + $this->add_sql_clause( 'select', TimeInterval::db_datetime_format( $interval, $table_name ) ); } - - $intervals_query = array_merge( $intervals_query, $this->get_limit_sql_params( $query_args ) ); - - $intervals_query = array_merge( $intervals_query, $this->get_order_by_sql_params( $query_args ) ); - - return $intervals_query; } /** @@ -815,6 +874,35 @@ class DataStore { return wc_get_products( $args ); } + /** + * Get WHERE filter by object ids subquery. + * + * @param string $select_table Select table name. + * @param string $select_field Select table object ID field name. + * @param string $filter_table Lookup table name. + * @param string $filter_field Lookup table object ID field name. + * @param string $compare Comparison string (IN|NOT IN). + * @param string $id_list Comma separated ID list. + * + * @return string + */ + protected function get_object_where_filter( $select_table, $select_field, $filter_table, $filter_field, $compare, $id_list ) { + global $wpdb; + if ( empty( $id_list ) ) { + return ''; + } + + $lookup_name = isset( $wpdb->$filter_table ) ? $wpdb->$filter_table : $wpdb->prefix . $filter_table; + return " {$select_table}.{$select_field} {$compare} ( + SELECT + DISTINCT {$filter_table}.{$select_field} + FROM + {$filter_table} + WHERE + {$filter_table}.{$filter_field} IN ({$id_list}) + )"; + } + /** * Returns an array of ids of allowed products, based on query arguments from the user. * @@ -864,15 +952,11 @@ class DataStore { * @return string */ protected function get_included_variations( $query_args ) { - $included_variations = array(); - $operator = $this->get_match_operator( $query_args ); - if ( isset( $query_args['variations'] ) && is_array( $query_args['variations'] ) && count( $query_args['variations'] ) > 0 ) { - $included_variations = array_filter( array_map( 'intval', $query_args['variations'] ) ); + $query_args['variations'] = array_filter( array_map( 'intval', $query_args['variations'] ) ); } - $included_variations_str = implode( ',', $included_variations ); - return $included_variations_str; + return $this->get_filtered_ids( $query_args, 'variations' ); } /** @@ -882,27 +966,28 @@ class DataStore { * @return string */ protected function get_excluded_products( $query_args ) { - $excluded_products_str = ''; + return $this->get_filtered_ids( $query_args, 'product_excludes' ); + } - if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) { - $excluded_products_str = implode( ',', $query_args['product_excludes'] ); - } - return $excluded_products_str; + /** + * Returns comma separated ids of included categories, based on query arguments from the user. + * + * @param array $query_args Parameters supplied by the user. + * @return string + */ + protected function get_included_categories( $query_args ) { + return $this->get_filtered_ids( $query_args, 'categories' ); } /** * Returns comma separated ids of included coupons, based on query arguments from the user. * - * @param array $query_args Parameters supplied by the user. + * @param array $query_args Parameters supplied by the user. + * @param string $field Field name in the parameter list. * @return string */ - protected function get_included_coupons( $query_args ) { - $included_coupons_str = ''; - - if ( isset( $query_args['coupon_includes'] ) && is_array( $query_args['coupon_includes'] ) && count( $query_args['coupon_includes'] ) > 0 ) { - $included_coupons_str = implode( ',', $query_args['coupon_includes'] ); - } - return $included_coupons_str; + protected function get_included_coupons( $query_args, $field = 'coupon_includes' ) { + return $this->get_filtered_ids( $query_args, $field ); } /** @@ -912,12 +997,7 @@ class DataStore { * @return string */ protected function get_excluded_coupons( $query_args ) { - $excluded_coupons_str = ''; - - if ( isset( $query_args['coupon_excludes'] ) && is_array( $query_args['coupon_excludes'] ) && count( $query_args['coupon_excludes'] ) > 0 ) { - $excluded_coupons_str = implode( ',', $query_args['coupon_excludes'] ); - } - return $excluded_coupons_str; + return $this->get_filtered_ids( $query_args, 'coupon_excludes' ); } /** @@ -927,12 +1007,7 @@ class DataStore { * @return string */ protected function get_included_orders( $query_args ) { - $included_orders_str = ''; - - if ( isset( $query_args['order_includes'] ) && is_array( $query_args['order_includes'] ) && count( $query_args['order_includes'] ) > 0 ) { - $included_orders_str = implode( ',', $query_args['order_includes'] ); - } - return $included_orders_str; + return $this->get_filtered_ids( $query_args, 'order_includes' ); } /** @@ -942,12 +1017,7 @@ class DataStore { * @return string */ protected function get_excluded_orders( $query_args ) { - $excluded_orders_str = ''; - - if ( isset( $query_args['order_excludes'] ) && is_array( $query_args['order_excludes'] ) && count( $query_args['order_excludes'] ) > 0 ) { - $excluded_orders_str = implode( ',', $query_args['order_excludes'] ); - } - return $excluded_orders_str; + return $this->get_filtered_ids( $query_args, 'order_excludes' ); } /** @@ -957,12 +1027,7 @@ class DataStore { * @return string */ protected function get_included_users( $query_args ) { - $included_users_str = ''; - - if ( isset( $query_args['user_includes'] ) && is_array( $query_args['user_includes'] ) && count( $query_args['user_includes'] ) > 0 ) { - $included_users_str = implode( ',', $query_args['user_includes'] ); - } - return $included_users_str; + return $this->get_filtered_ids( $query_args, 'user_includes' ); } /** @@ -972,12 +1037,7 @@ class DataStore { * @return string */ protected function get_excluded_users( $query_args ) { - $excluded_users_str = ''; - - if ( isset( $query_args['user_excludes'] ) && is_array( $query_args['user_excludes'] ) && count( $query_args['user_excludes'] ) > 0 ) { - $excluded_users_str = implode( ',', $query_args['user_excludes'] ); - } - return $excluded_users_str; + return $this->get_filtered_ids( $query_args, 'user_excludes' ); } /** @@ -1016,6 +1076,56 @@ class DataStore { return implode( " $operator ", $subqueries ); } + /** + * Add order status SQL clauses if included in query. + * + * @param array $query_args Parameters supplied by the user. + * @param string $table_name Database table name. + * @param SqlQuery $sql_query Query object. + */ + protected function add_order_status_clause( $query_args, $table_name, &$sql_query ) { + global $wpdb; + $order_status_filter = $this->get_status_subquery( $query_args ); + if ( $order_status_filter ) { + $sql_query->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); + $sql_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); + } + } + + /** + * Add order by SQL clause if included in query. + * + * @param array $query_args Parameters supplied by the user. + * @param SqlQuery $sql_query Query object. + * @return string Order by clause. + */ + protected function add_order_by_clause( $query_args, &$sql_query ) { + $order_by_clause = ''; + + $sql_query->clear_sql_clause( array( 'order_by' ) ); + if ( isset( $query_args['orderby'] ) ) { + $order_by_clause = $this->normalize_order_by( $query_args['orderby'] ); + $sql_query->add_sql_clause( 'order_by', $order_by_clause ); + } + + // Return ORDER BY clause to allow adding the sort field(s) to query via a JOIN. + return $order_by_clause; + } + + /** + * Add order by order SQL clause. + * + * @param array $query_args Parameters supplied by the user. + * @param SqlQuery $sql_query Query object. + */ + protected function add_orderby_order_clause( $query_args, &$sql_query ) { + if ( isset( $query_args['order'] ) ) { + $sql_query->add_sql_clause( 'order_by', $query_args['order'] ); + } else { + $sql_query->add_sql_clause( 'order_by', 'DESC' ); + } + } + /** * Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user. * @@ -1057,4 +1167,39 @@ class DataStore { } return $operator; } + + /** + * Returns filtered comma separated ids, based on query arguments from the user. + * + * @param array $query_args Parameters supplied by the user. + * @param string $field Query field to filter. + * @param string $separator Field separator. + * @return string + */ + protected function get_filtered_ids( $query_args, $field, $separator = ',' ) { + $ids_str = ''; + $ids = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array(); + + /** + * Filter the IDs before retrieving report data. + * + * Allows filtering of the objects included or excluded from reports. + * + * @param array $ids List of object Ids. + * @param array $query_args The original arguments for the request. + * @param string $field The object type. + * @param string $context The data store context. + */ + $ids = apply_filters( 'wc_admin_reports_ ' . $field, $ids, $query_args, $field, $this->context ); + + if ( ! empty( $ids ) ) { + $ids_str = implode( $separator, $ids ); + } + return $ids_str; + } + + /** + * Assign report columns once full table name has been assigned. + */ + protected function assign_report_columns() {} } diff --git a/plugins/woocommerce-admin/src/API/Reports/Downloads/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Downloads/DataStore.php index 608e7d91cc4..cc6018b12d9 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Downloads/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Downloads/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Downloads\DataStore. @@ -23,7 +24,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_download_log'; + protected static $table_name = 'wc_download_log'; /** * Cache identifier. @@ -50,139 +51,136 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query and their mapping to SQL code. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'id' => 'download_log_id as id', - 'date' => 'timestamp as date_gmt', - 'download_id' => 'product_permissions.download_id', - 'product_id' => 'product_permissions.product_id', - 'order_id' => 'product_permissions.order_id', - 'user_id' => 'product_permissions.user_id', - 'ip_address' => 'user_ip_address as ip_address', - ); + protected $context = 'downloads'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; + protected function assign_report_columns() { + $this->report_columns = array( + 'id' => 'download_log_id as id', + 'date' => 'timestamp as date_gmt', + 'download_id' => 'product_permissions.download_id', + 'product_id' => 'product_permissions.product_id', + 'order_id' => 'product_permissions.order_id', + 'user_id' => 'product_permissions.user_id', + 'ip_address' => 'user_ip_address as ip_address', + ); } /** * Updates the database query with parameters used for downloads report. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $lookup_table = $wpdb->prefix . self::TABLE_NAME; - $operator = $this->get_match_operator( $query_args ); - $where_filters = array(); + $lookup_table = self::get_db_table_name(); + $permission_table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions'; + $operator = $this->get_match_operator( $query_args ); + $where_filters = array(); + $join = "JOIN {$permission_table} as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id"; - $sql_query_params = $this->get_time_period_sql_params( $query_args, $lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - - $included_products = $this->get_included_products( $query_args ); - $excluded_products = $this->get_excluded_products( $query_args ); - if ( $included_products ) { - $where_filters[] = " {$lookup_table}.permission_id IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.product_id IN ({$included_products}) - )"; + $where_time = $this->get_time_period_sql_params( $query_args, $lookup_table ); + if ( $where_time ) { + if ( isset( $this->subquery ) ) { + $this->subquery->add_sql_clause( 'where_time', $where_time ); + } else { + $this->interval_query->add_sql_clause( 'where_time', $where_time ); + } } + $this->get_limit_sql_params( $query_args ); - if ( $excluded_products ) { - $where_filters[] = " {$lookup_table}.permission_id NOT IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.product_id IN ({$excluded_products}) - )"; - } - - $included_orders = $this->get_included_orders( $query_args ); - $excluded_orders = $this->get_excluded_orders( $query_args ); - if ( $included_orders ) { - $where_filters[] = " {$lookup_table}.permission_id IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.order_id IN ({$included_orders}) - )"; - } - - if ( $excluded_orders ) { - $where_filters[] = " {$lookup_table}.permission_id NOT IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.order_id IN ({$excluded_orders}) - )"; - } + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'product_id', + 'IN', + $this->get_included_products( $query_args ) + ); + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'product_id', + 'NOT IN', + $this->get_excluded_products( $query_args ) + ); + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'order_id', + 'IN', + $this->get_included_orders( $query_args ) + ); + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'order_id', + 'NOT IN', + $this->get_excluded_orders( $query_args ) + ); $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; + $customer_lookup = "SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN (%s)"; $included_customers = $this->get_included_customers( $query_args ); $excluded_customers = $this->get_excluded_customers( $query_args ); if ( $included_customers ) { - $where_filters[] = " {$lookup_table}.permission_id IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.user_id IN ( - SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN ({$included_customers}) - ) - )"; + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'user_id', + 'IN', + sprintf( $customer_lookup, $included_customers ) + ); } if ( $excluded_customers ) { - $where_filters[] = " {$lookup_table}.permission_id NOT IN ( - SELECT - DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id - FROM - {$wpdb->prefix}woocommerce_downloadable_product_permissions - WHERE - {$wpdb->prefix}woocommerce_downloadable_product_permissions.user_id IN ( - SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN ({$excluded_customers}) - ) - )"; + $where_filters[] = $this->get_object_where_filter( + $lookup_table, + 'permission_id', + $permission_table, + 'user_id', + 'NOT IN', + sprintf( $customer_lookup, $excluded_customers ) + ); } $included_ip_addresses = $this->get_included_ip_addresses( $query_args ); $excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args ); if ( $included_ip_addresses ) { - $where_filters[] = " {$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')"; + $where_filters[] = "{$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')"; } if ( $excluded_ip_addresses ) { - $where_filters[] = " {$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')"; + $where_filters[] = "{$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')"; } + $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); if ( $where_subclause ) { - $sql_query_params['where_clause'] .= " AND ( $where_subclause )"; + if ( isset( $this->subquery ) ) { + $this->subquery->add_sql_clause( 'where', "AND ( $where_subclause )" ); + } else { + $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); + } } - $sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}woocommerce_downloadable_product_permissions as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id"; - $sql_query_params = $this->get_order_by( $query_args, $sql_query_params ); - - return $sql_query_params; + if ( isset( $this->subquery ) ) { + $this->subquery->add_sql_clause( 'join', $join ); + } else { + $this->interval_query->add_sql_clause( 'join', $join ); + } + $this->get_order_by( $query_args ); } /** @@ -192,16 +190,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function get_included_ip_addresses( $query_args ) { - $included_ips_str = ''; - if ( isset( $query_args['ip_address_includes'] ) && is_array( $query_args['ip_address_includes'] ) && count( $query_args['ip_address_includes'] ) > 0 ) { - $ip_includes = array(); - foreach ( $query_args['ip_address_includes'] as $ip ) { - $ip_includes[] = esc_sql( $ip ); - } - $included_ips_str = implode( "','", $ip_includes ); + $query_args['ip_address_includes'] = array_map( 'esc_sql', $query_args['ip_address_includes'] ); } - return $included_ips_str; + return self::get_filtered_ids( $query_args, 'ip_address_includes', "','" ); } /** @@ -211,16 +203,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function get_excluded_ip_addresses( $query_args ) { - $excluded_ips_str = ''; - if ( isset( $query_args['ip_address_excludes'] ) && is_array( $query_args['ip_address_excludes'] ) && count( $query_args['ip_address_excludes'] ) > 0 ) { - $ip_excludes = array(); - foreach ( $query_args['ip_address_excludes'] as $ip ) { - $ip_excludes[] = esc_sql( $ip ); - } - $excluded_ips_str = implode( ',', $ip_excludes ); + $query_args['ip_address_excludes'] = array_map( 'esc_sql', $query_args['ip_address_excludes'] ); } - return $excluded_ips_str; + return self::get_filtered_ids( $query_args, 'ip_address_excludes', "','" ); } /** @@ -230,12 +216,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function get_included_customers( $query_args ) { - $included_customers_str = ''; - - if ( isset( $query_args['customer_includes'] ) && is_array( $query_args['customer_includes'] ) && count( $query_args['customer_includes'] ) > 0 ) { - $included_customers_str = implode( ',', $query_args['customer_includes'] ); - } - return $included_customers_str; + return self::get_filtered_ids( $query_args, 'customer_includes' ); } /** @@ -245,67 +226,51 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function get_excluded_customers( $query_args ) { - $excluded_customer_str = ''; - - if ( isset( $query_args['customer_excludes'] ) && is_array( $query_args['customer_excludes'] ) && count( $query_args['customer_excludes'] ) > 0 ) { - $excluded_customer_str = implode( ',', $query_args['customer_excludes'] ); - } - return $excluded_customer_str; + return self::get_filtered_ids( $query_args, 'customer_excludes' ); } /** - * Fills WHERE clause of SQL request with date-related constraints. + * Gets WHERE time clause of SQL request with date-related constraints. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. - * @return array + * @return string */ protected function get_time_period_sql_params( $query_args, $table_name ) { - $sql_query = array( - 'from_clause' => '', - 'where_time_clause' => '', - 'where_clause' => '', - ); - + $where_time = ''; if ( $query_args['before'] ) { - $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); - $sql_query['where_time_clause'] .= " AND {$table_name}.timestamp <= '$datetime_str'"; + $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); + $where_time .= " AND {$table_name}.timestamp <= '$datetime_str'"; } if ( $query_args['after'] ) { - $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); - $sql_query['where_time_clause'] .= " AND {$table_name}.timestamp >= '$datetime_str'"; + $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); + $where_time .= " AND {$table_name}.timestamp >= '$datetime_str'"; } - return $sql_query; + return $where_time; } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @param array $sql_query Current SQL query array. - * @return array */ - protected function get_order_by( $query_args, $sql_query ) { + protected function get_order_by( $query_args ) { global $wpdb; - $sql_query['order_by_clause'] = ''; + $this->clear_sql_clause( 'order_by' ); + $order_by = ''; if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); + $order_by = $this->normalize_order_by( $query_args['orderby'] ); + $this->add_sql_clause( 'order_by', $order_by ); } - if ( false !== strpos( $sql_query['order_by_clause'], '_products' ) ) { - $sql_query['from_clause'] .= " JOIN {$wpdb->prefix}posts AS _products ON product_permissions.product_id = _products.ID"; + if ( false !== strpos( $order_by, '_products' ) ) { + $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->posts} AS _products ON product_permissions.product_id = _products.ID" ); } - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; - } else { - $sql_query['order_by_clause'] .= ' DESC'; - } - - return $sql_query; + $this->add_orderby_order_clause( $query_args, $this ); } /** @@ -317,7 +282,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -340,6 +305,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -352,41 +319,23 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - {$table_name}.download_log_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$table_name}.download_log_id - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $params = $this->get_limit_params( $query_args ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $download_data = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$table_name}.download_log_id - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $this->subquery->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -427,4 +376,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $order_by; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $table_name = self::get_db_table_name(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'from', $table_name ); + $this->subquery->add_sql_clause( 'select', "{$table_name}.download_log_id" ); + $this->subquery->add_sql_clause( 'group_by', "{$table_name}.download_log_id" ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Downloads/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Downloads/Stats/DataStore.php index cf368955fd8..c279f8cb312 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Downloads/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Downloads/Stats/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Downloads\Stats\DataStore. @@ -27,15 +28,6 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { 'download_count' => 'intval', ); - /** - * SQL columns to select in the db query and their mapping to SQL code. - * - * @var array - */ - protected $report_columns = array( - 'download_count' => 'COUNT(DISTINCT download_log_id) as download_count', - ); - /** * Cache identifier. * @@ -44,12 +36,20 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { protected $cache_key = 'downloads_stats'; /** - * Constructor + * Data store context used to pass to filters. + * + * @var string */ - public function __construct() { - global $wpdb; - } + protected $context = 'download_stats'; + /** + * Assign report columns once full table name has been assigned. + */ + protected function assign_report_columns() { + $this->report_columns = array( + 'download_count' => 'COUNT(DISTINCT download_log_id) as download_count', + ); + } /** * Returns the report data based on parameters supplied by the user. @@ -60,7 +60,7 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -84,53 +84,35 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { - $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->get_sql_query_params( $query_args ); - $totals_query = array_merge( array(), $this->get_time_period_sql_params( $query_args, $table_name ) ); - $intervals_query = array_merge( array(), $this->get_intervals_sql_params( $query_args, $table_name ) ); + $this->initialize_queries(); + $selections = $this->selected_columns( $query_args ); + $this->get_sql_query_params( $query_args ); + $this->get_time_period_sql_params( $query_args, $table_name ); + $this->get_intervals_sql_params( $query_args, $table_name ); - $totals_query['where_clause'] .= $sql_query_params['where_clause']; - $totals_query['from_clause'] .= $sql_query_params['from_clause']; - $intervals_query['where_clause'] .= $sql_query_params['where_clause']; - $intervals_query['from_clause'] .= $sql_query_params['from_clause']; - $intervals_query['select_clause'] = str_replace( 'date_created', 'timestamp', $intervals_query['select_clause'] ); - $intervals_query['where_time_clause'] = str_replace( 'date_created', 'timestamp', $intervals_query['where_time_clause'] ); + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); + $this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' ); + $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); $db_intervals = $wpdb->get_col( - "SELECT - {$intervals_query['select_clause']} AS time_interval - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval" + $this->interval_query->get_query_statement() ); // WPCS: cache ok, DB call ok, , unprepared SQL ok. $db_records_count = count( $db_intervals ); + $params = $this->get_limit_params( $query_args ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] ); + $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return array(); } - $this->update_intervals_sql_params( $intervals_query, $query_args, $db_records_count, $expected_interval_count, $table_name ); - $intervals_query['where_time_clause'] = str_replace( 'date_created', 'timestamp', $intervals_query['where_time_clause'] ); - + $this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name ); + $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) ); $totals = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$totals_query['from_clause']} - WHERE - 1=1 - {$totals_query['where_time_clause']} - {$totals_query['where_clause']}", + $this->total_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -138,27 +120,15 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { return new \WP_Error( 'woocommerce_reports_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce-admin' ) ); } + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' ); if ( '' !== $selections ) { - $selections = ', ' . $selections; + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( - "SELECT - MAX(timestamp) AS datetime_anchor, - {$intervals_query['select_clause']} AS time_interval - {$selections} - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval - ORDER BY - {$intervals_query['order_by_clause']} - {$intervals_query['limit']}", + $this->interval_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -175,10 +145,10 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { 'page_no' => (int) $query_args['page'], ); - if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } @@ -203,4 +173,18 @@ class DataStore extends DownloadsDataStore implements DataStoreInterface { return $order_by; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Orders/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Orders/DataStore.php index 3e090335eec..69d454b19e7 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Orders/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Orders/DataStore.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Orders\DataStore. @@ -22,7 +23,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_stats'; + protected static $table_name = 'wc_order_stats'; /** * Cache identifier. @@ -50,18 +51,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query and their mapping to SQL code. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array(); + protected $context = 'orders'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); // Avoid ambigious columns in SQL query. $this->report_columns = array( 'order_id' => "{$table_name}.order_id", @@ -81,22 +81,23 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * Updates the database query with parameters used for orders report: coupons and products filters. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_stats_lookup_table = $wpdb->prefix . self::TABLE_NAME; - $operator = $this->get_match_operator( $query_args ); - $where_subquery = array(); + $order_stats_lookup_table = self::get_db_table_name(); + $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; + $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; + $operator = $this->get_match_operator( $query_args ); + $where_subquery = array(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_stats_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_stats_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); $status_subquery = $this->get_status_subquery( $query_args ); if ( $status_subquery ) { if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) { - $sql_query_params['where_clause'] .= " AND {$status_subquery}"; + $this->subquery->add_sql_clause( 'where', "AND {$status_subquery}" ); } else { $where_subquery[] = $status_subquery; } @@ -117,17 +118,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = ${returning_customer}"; } - $refund_subquery = $this->get_refund_subquery( $query_args ); - $sql_query_params['from_clause'] .= $refund_subquery['from_clause'] ? $refund_subquery['from_clause'] : ''; + $refund_subquery = $this->get_refund_subquery( $query_args ); + $this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] ); if ( $refund_subquery['where_clause'] ) { $where_subquery[] = $refund_subquery['where_clause']; } - $included_coupons = $this->get_included_coupons( $query_args ); - $excluded_coupons = $this->get_excluded_coupons( $query_args ); - $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; + $included_coupons = $this->get_included_coupons( $query_args ); + $excluded_coupons = $this->get_excluded_coupons( $query_args ); if ( $included_coupons || $excluded_coupons ) { - $sql_query_params['from_clause'] .= " JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id"; + $this->subquery->add_sql_clause( 'join', "JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id" ); } if ( $included_coupons ) { $where_subquery[] = "{$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; @@ -136,11 +136,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $where_subquery[] = "{$order_coupon_lookup_table}.coupon_id NOT IN ({$excluded_coupons})"; } - $included_products = $this->get_included_products( $query_args ); - $excluded_products = $this->get_excluded_products( $query_args ); - $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; + $included_products = $this->get_included_products( $query_args ); + $excluded_products = $this->get_excluded_products( $query_args ); if ( $included_products || $excluded_products ) { - $sql_query_params['from_clause'] .= " JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id"; + $this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" ); } if ( $included_products ) { $where_subquery[] = "{$order_product_lookup_table}.product_id IN ({$included_products})"; @@ -150,10 +149,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } if ( 0 < count( $where_subquery ) ) { - $sql_query_params['where_clause'] .= ' AND (' . implode( " {$operator} ", $where_subquery ) . ')'; + $this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' ); } - - return $sql_query_params; } /** @@ -165,7 +162,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -198,6 +195,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -205,26 +204,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->get_sql_query_params( $query_args ); + $selections = $this->selected_columns( $query_args ); + $params = $this->get_limit_params( $query_args ); + $this->get_sql_query_params( $query_args ); $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - {$table_name}.order_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - if ( 0 === $sql_query_params['per_page'] ) { + if ( 0 === $params['per_page'] ) { $total_pages = 0; } else { - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); } if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { $data = (object) array( @@ -236,20 +228,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $data; } + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $orders_data = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $this->subquery->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -271,7 +255,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $this->set_cached_data( $cache_key, $data ); } - return $data; } @@ -368,8 +351,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $products = $wpdb->get_results( "SELECT order_id, ID as product_id, post_title as product_name, product_qty as product_quantity - FROM {$wpdb->prefix}posts - JOIN {$order_product_lookup_table} ON {$order_product_lookup_table}.product_id = {$wpdb->prefix}posts.ID + FROM {$wpdb->posts} + JOIN {$order_product_lookup_table} ON {$order_product_lookup_table}.product_id = {$wpdb->posts}.ID WHERE order_id IN ({$included_order_ids}) ", @@ -423,8 +406,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $coupons = $wpdb->get_results( "SELECT order_id, coupon_id, post_title as coupon_code - FROM {$wpdb->prefix}posts - JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->prefix}posts.ID + FROM {$wpdb->posts} + JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->posts}.ID WHERE order_id IN ({$included_order_ids}) ", @@ -433,4 +416,14 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $coupons; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.order_id' ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Orders/Stats/DataStore.php index 0fbd426f829..a1cc28cff6b 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Orders/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Orders/Stats/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** @@ -24,7 +25,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_stats'; + protected static $table_name = 'wc_order_stats'; /** * Cron event name. @@ -62,18 +63,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL definition for each column. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array(); + protected $context = 'order_stats'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); // Avoid ambigious columns in SQL query. $this->report_columns = array( 'orders_count' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) as orders_count", @@ -104,81 +104,63 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * Updates the totals and intervals database queries with parameters used for Orders report: categories, coupons and order status. * * @param array $query_args Query arguments supplied by the user. - * @param array $totals_query Array of options for totals db query. - * @param array $intervals_query Array of options for intervals db query. */ - protected function orders_stats_sql_filter( $query_args, &$totals_query, &$intervals_query ) { + protected function orders_stats_sql_filter( $query_args ) { // @todo Performance of all of this? global $wpdb; $from_clause = ''; - $orders_stats_table = $wpdb->prefix . self::TABLE_NAME; + $orders_stats_table = self::get_db_table_name(); + $product_lookup = $wpdb->prefix . 'wc_order_product_lookup'; + $coupon_lookup = $wpdb->prefix . 'wc_order_coupon_lookup'; $operator = $this->get_match_operator( $query_args ); $where_filters = array(); - // @todo Maybe move the sql inside the get_included/excluded functions? // Products filters. - $included_products = $this->get_included_products( $query_args ); - $excluded_products = $this->get_excluded_products( $query_args ); - if ( $included_products ) { - $where_filters[] = " {$orders_stats_table}.order_id IN ( - SELECT - DISTINCT {$wpdb->prefix}wc_order_product_lookup.order_id - FROM - {$wpdb->prefix}wc_order_product_lookup - WHERE - {$wpdb->prefix}wc_order_product_lookup.product_id IN ({$included_products}) - )"; - } - - if ( $excluded_products ) { - $where_filters[] = " {$orders_stats_table}.order_id NOT IN ( - SELECT - DISTINCT {$wpdb->prefix}wc_order_product_lookup.order_id - FROM - {$wpdb->prefix}wc_order_product_lookup - WHERE - {$wpdb->prefix}wc_order_product_lookup.product_id IN ({$excluded_products}) - )"; - } + $where_filters[] = $this->get_object_where_filter( + $orders_stats_table, + 'order_id', + $product_lookup, + 'product_id', + 'IN', + $this->get_included_products( $query_args ) + ); + $where_filters[] = $this->get_object_where_filter( + $orders_stats_table, + 'order_id', + $product_lookup, + 'product_id', + 'NOT IN', + $this->get_excluded_products( $query_args ) + ); // Coupons filters. - $included_coupons = $this->get_included_coupons( $query_args ); - $excluded_coupons = $this->get_excluded_coupons( $query_args ); - if ( $included_coupons ) { - $where_filters[] = " {$orders_stats_table}.order_id IN ( - SELECT - DISTINCT {$wpdb->prefix}wc_order_coupon_lookup.order_id - FROM - {$wpdb->prefix}wc_order_coupon_lookup - WHERE - {$wpdb->prefix}wc_order_coupon_lookup.coupon_id IN ({$included_coupons}) - )"; - } - - if ( $excluded_coupons ) { - $where_filters[] = " {$orders_stats_table}.order_id NOT IN ( - SELECT - DISTINCT {$wpdb->prefix}wc_order_coupon_lookup.order_id - FROM - {$wpdb->prefix}wc_order_coupon_lookup - WHERE - {$wpdb->prefix}wc_order_coupon_lookup.coupon_id IN ({$excluded_coupons}) - )"; - } - - $customer_filter = $this->get_customer_subquery( $query_args ); - if ( $customer_filter ) { - $where_filters[] = $customer_filter; - } + $where_filters[] = $this->get_object_where_filter( + $orders_stats_table, + 'order_id', + $coupon_lookup, + 'coupon_id', + 'IN', + $this->get_included_coupons( $query_args ) + ); + $where_filters[] = $this->get_object_where_filter( + $orders_stats_table, + 'order_id', + $coupon_lookup, + 'coupon_id', + 'NOT IN', + $this->get_excluded_coupons( $query_args ) + ); + $where_filters[] = $this->get_customer_subquery( $query_args ); $refund_subquery = $this->get_refund_subquery( $query_args ); + $from_clause .= $refund_subquery['from_clause']; if ( $refund_subquery['where_clause'] ) { $where_filters[] = $refund_subquery['where_clause']; - $from_clause .= $refund_subquery['from_clause']; } + $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); // Append status filter after to avoid matching ANY on default statuses. @@ -192,10 +174,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { // To avoid requesting the subqueries twice, the result is applied to all queries passed to the method. if ( $where_subclause ) { - $totals_query['where_clause'] .= " AND ( $where_subclause )"; - $totals_query['from_clause'] .= $from_clause; - $intervals_query['where_clause'] .= " AND ( $where_subclause )"; - $intervals_query['from_clause'] .= $from_clause; + $this->total_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); + $this->total_query->add_sql_clause( 'join', $from_clause ); + $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); + $this->interval_query->add_sql_clause( 'join', $from_clause ); } } @@ -208,7 +190,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only applied when not using REST API, as the API has its own defaults that overwrite these for most values (except before, after, etc). $defaults = array( @@ -243,6 +225,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'totals' => (object) array(), 'intervals' => (object) array(), @@ -251,10 +235,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $selections = $this->selected_columns( $query_args ); - $totals_query = $this->get_time_period_sql_params( $query_args, $table_name ); - $intervals_query = $this->get_intervals_sql_params( $query_args, $table_name ); - $coupon_join = "LEFT JOIN ( + $selections = $this->selected_columns( $query_args ); + $this->get_time_period_sql_params( $query_args, $table_name ); + $this->get_intervals_sql_params( $query_args, $table_name ); + $this->get_order_by_sql_params( $query_args ); + $where_time = $this->get_sql_clause( 'where_time' ); + $params = $this->get_limit_sql_params( $query_args ); + $coupon_join = "LEFT JOIN ( SELECT order_id, SUM(discount_amount) AS discount_amount, @@ -267,25 +254,32 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id"; // Additional filtering for Orders report. - $this->orders_stats_sql_filter( $query_args, $totals_query, $intervals_query ); - + $this->orders_stats_sql_filter( $query_args ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'left_join', $coupon_join ); + $this->total_query->add_sql_clause( 'where_time', $where_time ); $totals = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$totals_query['from_clause']} - {$coupon_join} - WHERE - 1=1 - {$totals_query['where_time_clause']} - {$totals_query['where_clause']}", + $this->total_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. if ( null === $totals ) { return new WP_Error( 'woocommerce_reports_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce-admin' ) ); } + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $where_time, + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $where_time, + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); + $unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); $totals[0]['products'] = $unique_products; $segmenter = new Segmenter( $query_args, $this->report_columns ); @@ -294,53 +288,30 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); + $this->interval_query->add_sql_clause( 'left_join', $coupon_join ); + $this->interval_query->add_sql_clause( 'where_time', $where_time ); $db_intervals = $wpdb->get_col( - "SELECT - {$intervals_query['select_clause']} AS time_interval - FROM - {$table_name} - {$intervals_query['from_clause']} - {$coupon_join} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval" + $this->interval_query->get_query_statement() ); // WPCS: cache ok, DB call ok, , unprepared SQL ok. $db_interval_count = count( $db_intervals ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] ); + $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - $this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name ); - + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { - $selections = ', ' . $selections; + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } - $intervals = $wpdb->get_results( - "SELECT - MAX({$table_name}.date_created) AS datetime_anchor, - {$intervals_query['select_clause']} AS time_interval - {$selections} - FROM - {$table_name} - {$intervals_query['from_clause']} - {$coupon_join} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval - ORDER BY - {$intervals_query['order_by_clause']} - {$intervals_query['limit']}", + $this->interval_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -361,10 +332,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => (int) $query_args['page'], ); - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } @@ -388,8 +359,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_unique_product_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - + $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT( DISTINCT {$wpdb->prefix}wc_order_product_lookup.product_id ) @@ -414,7 +384,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_unique_coupon_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT(DISTINCT coupon_id) @@ -455,7 +425,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { */ public static function update( $order ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); if ( ! $order->get_id() || ! $order->get_date_created() ) { return -1; @@ -521,20 +491,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { */ public static function delete_order( $post_id ) { global $wpdb; - $order_id = (int) $post_id; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $order_id = (int) $post_id; if ( 'shop_order' !== get_post_type( $order_id ) && 'shop_order_refund' !== get_post_type( $order_id ) ) { return; } - $wpdb->query( - $wpdb->prepare( - "DELETE FROM ${table_name} WHERE order_id = %d", - $order_id - ) - ); - + $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when orders stats are deleted. * @@ -634,7 +597,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { */ protected static function set_customer_first_order( $customer_id, $order_id ) { global $wpdb; - $orders_stats_table = $wpdb->prefix . self::TABLE_NAME; + $orders_stats_table = self::get_db_table_name(); $wpdb->query( $wpdb->prepare( @@ -644,4 +607,18 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ) ); } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Products/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Products/DataStore.php index 37f31de9fb9..43e19f396a8 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Products/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Products/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** @@ -24,7 +25,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_product_lookup'; + protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. @@ -58,18 +59,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'sku' => 'strval', ); - /** - * SQL columns to select in the db query and their mapping to SQL code. - * - * @var array - */ - protected $report_columns = array( - 'product_id' => 'product_id', - 'items_sold' => 'SUM(product_qty) as items_sold', - 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', - 'orders_count' => 'COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN order_id END ) ) as orders_count', - ); - /** * Extended product attributes to include in the data. * @@ -90,13 +79,23 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * Constructor + * Data store context used to pass to filters. + * + * @var string */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected $context = 'products'; + + /** + * Assign report columns once full table name has been assigned. + */ + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'product_id' => 'product_id', + 'items_sold' => 'SUM(product_qty) as items_sold', + 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', + 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", + ); } /** @@ -110,62 +109,69 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @param string $arg_name Name of the FROM sql param. + * @param string $arg_name Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. - * @return array */ protected function get_from_sql_params( $query_args, $arg_name, $id_cell ) { global $wpdb; - $sql_query['outer_from_clause'] = ''; + $type = 'join'; // Order by product name requires extra JOIN. - if ( 'product_name' === $query_args['orderby'] ) { - $sql_query[ $arg_name ] .= " JOIN {$wpdb->prefix}posts AS _products ON {$id_cell} = _products.ID"; + switch ( $query_args['orderby'] ) { + case 'product_name': + $join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID"; + break; + case 'sku': + $join = " JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'"; + break; + case 'variations': + $type = 'left_join'; + $join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent"; + break; + default: + $join = ''; + break; } - if ( 'sku' === $query_args['orderby'] ) { - $sql_query[ $arg_name ] .= " JOIN {$wpdb->prefix}postmeta AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'"; + if ( $join ) { + if ( 'inner' === $arg_name ) { + $this->subquery->add_sql_clause( $type, $join ); + } else { + $this->add_sql_clause( $type, $join ); + } } - if ( 'variations' === $query_args['orderby'] ) { - $sql_query[ $arg_name ] .= " LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->prefix}posts WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent"; - } - - return $sql_query; } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_product_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { - $sql_query_params = array_merge( $sql_query_params, $this->get_from_sql_params( $query_args, 'outer_from_clause', 'default_results.product_id' ) ); - $sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; + $this->get_from_sql_params( $query_args, 'outer', 'default_results.product_id' ); + $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } else { - $sql_query_params = array_merge( $sql_query_params, $this->get_from_sql_params( $query_args, 'from_clause', "{$order_product_lookup_table}.product_id" ) ); + $this->get_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" ); } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { - $sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})"; + $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { - $sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; + $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); + $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } - - return $sql_query_params; } /** @@ -175,11 +181,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function normalize_order_by( $order_by ) { - global $wpdb; - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; - if ( 'date' === $order_by ) { - return $order_product_lookup_table . '.date_created'; + return self::get_db_table_name() . '.date_created'; } if ( 'product_name' === $order_by ) { return 'post_title'; @@ -261,7 +264,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -287,6 +290,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -295,12 +300,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); $selections = $this->selected_columns( $query_args ); - $sql_query_params = $this->get_sql_query_params( $query_args ); $included_products = $this->get_included_products_array( $query_args ); + $params = $this->get_limit_params( $query_args ); + $this->get_sql_query_params( $query_args ); if ( count( $included_products ) > 0 ) { $total_results = count( $included_products ); - $total_pages = (int) ceil( $total_results / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); if ( 'date' === $query_args['orderby'] ) { $selections .= ", {$table_name}.date_created"; @@ -309,59 +315,43 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'product_id' ) ); $ids_table = $this->get_ids_table( $included_products, 'product_id' ); - $prefix = "SELECT {$join_selections} FROM ("; - $suffix = ") AS {$table_name}"; - $right_join = "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.product_id = {$table_name}.product_id"; + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->add_sql_clause( 'select', $join_selections ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.product_id = {$table_name}.product_id" + ); + + $products_query = $this->get_query_statement(); } else { $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - product_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - product_id - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) { return $data; } - $prefix = ''; - $suffix = ''; - $right_join = ''; + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $products_query = $this->subquery->get_query_statement(); } $product_data = $wpdb->get_results( - "${prefix} - SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - product_id - {$suffix} - {$right_join} - {$sql_query_params['outer_from_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $products_query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -433,7 +423,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { } $result = $wpdb->replace( - $wpdb->prefix . self::TABLE_NAME, + self::get_db_table_name(), array( 'order_item_id' => $order_item_id, 'order_id' => $order->get_id(), @@ -490,14 +480,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function sync_on_order_delete( $order_id ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - - $wpdb->query( - $wpdb->prepare( - "DELETE FROM ${table_name} WHERE order_id = %d", - $order_id - ) - ); + $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when product's reports are removed from database. @@ -509,4 +492,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ReportsCache::invalidate(); } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'select', 'product_id' ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + $this->subquery->add_sql_clause( 'group_by', 'product_id' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Products/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Products/Stats/DataStore.php index 698207fe2f3..476dae6fb51 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Products/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Products/Stats/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Products\Stats\DataStore. @@ -35,42 +36,37 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'items_sold' => 'SUM(product_qty) as items_sold', - 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', - 'orders_count' => 'COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN order_id END ) ) as orders_count', - 'products_count' => 'COUNT(DISTINCT product_id) as products_count', - 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', - ); + protected $context = 'product_stats'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'items_sold' => 'SUM(product_qty) as items_sold', + 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', + 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", + 'products_count' => 'COUNT(DISTINCT product_id) as products_count', + 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', + ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @param array $totals_params SQL parameters for the totals query. - * @param array $intervals_params SQL parameters for the intervals query. */ - protected function update_sql_query_params( $query_args, &$totals_params, &$intervals_params ) { + protected function update_sql_query_params( $query_args ) { global $wpdb; - $products_where_clause = ''; - $products_from_clause = ''; - - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $products_where_clause = ''; + $products_from_clause = ''; + $order_product_lookup_table = self::get_db_table_name(); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { @@ -88,13 +84,14 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { $products_where_clause .= " AND ( {$order_status_filter} )"; } - $totals_params = array_merge( $totals_params, $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ) ); - $totals_params['where_clause'] .= $products_where_clause; - $totals_params['from_clause'] .= $products_from_clause; + $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); + $this->total_query->add_sql_clause( 'where', $products_where_clause ); + $this->total_query->add_sql_clause( 'join', $products_from_clause ); - $intervals_params = array_merge( $intervals_params, $this->get_intervals_sql_params( $query_args, $order_product_lookup_table ) ); - $intervals_params['where_clause'] .= $products_where_clause; - $intervals_params['from_clause'] .= $products_from_clause; + $this->get_intervals_sql_params( $query_args, $order_product_lookup_table ); + $this->interval_query->add_sql_clause( 'where', $products_where_clause ); + $this->interval_query->add_sql_clause( 'join', $products_from_clause ); + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } /** @@ -107,7 +104,7 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -133,47 +130,50 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { - $selections = $this->selected_columns( $query_args ); - $totals_query = array(); - $intervals_query = array(); - $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); + $this->initialize_queries(); + + $selections = $this->selected_columns( $query_args ); + $params = $this->get_limit_params( $query_args ); + + $this->update_sql_query_params( $query_args ); + $this->get_limit_sql_params( $query_args ); + $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $db_intervals = $wpdb->get_col( - "SELECT - {$intervals_query['select_clause']} AS time_interval - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval" - ); // WPCS: cache ok, DB call ok, , unprepared SQL ok. + $this->interval_query->get_query_statement() + ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $db_interval_count = count( $db_intervals ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] ); + $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return array(); } - $this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $intervals = array(); + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$totals_query['from_clause']} - WHERE - 1=1 - {$totals_query['where_time_clause']} - {$totals_query['where_clause']}", + $this->total_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + 'order_by' => $this->get_sql_clause( 'order_by' ), + 'limit' => $this->get_sql_clause( 'limit' ), + ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); @@ -181,27 +181,15 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { return new \WP_Error( 'woocommerce_reports_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce-admin' ) ); } + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { - $selections = ', ' . $selections; + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( - "SELECT - MAX(${table_name}.date_created) AS datetime_anchor, - {$intervals_query['select_clause']} AS time_interval - {$selections} - FROM - {$table_name} - {$intervals_query['from_clause']} - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval - ORDER BY - {$intervals_query['order_by_clause']} - {$intervals_query['limit']}", + $this->interval_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -219,10 +207,10 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { 'page_no' => (int) $query_args['page'], ); - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } @@ -248,4 +236,18 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { return $order_by; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Segmenter.php b/plugins/woocommerce-admin/src/API/Reports/Segmenter.php index f0c29c45691..37d7962bb7e 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Segmenter.php +++ b/plugins/woocommerce-admin/src/API/Reports/Segmenter.php @@ -98,11 +98,15 @@ class Segmenter { $segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 ); } + $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { - $segment_id = $segment_data[ $segment_dimension ]; - $segment_labels = $this->get_segment_labels(); + $segment_id = $segment_data[ $segment_dimension ]; + if ( ! isset( $segment_labels[ $segment_id ] ) ) { + continue; + } + unset( $segment_data[ $segment_dimension ] ); - $segment_datum = array( + $segment_datum = array( 'segment_id' => $segment_id, 'segment_label' => $segment_labels[ $segment_id ], 'subtotals' => $segment_data, @@ -292,6 +296,11 @@ class Segmenter { $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { + $segment_id = $segment_data[ $segment_dimension ]; + if ( ! isset( $segment_labels[ $segment_id ] ) ) { + continue; + } + $time_interval = $segment_data['time_interval']; if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) { $aggregated_segment_result[ $time_interval ] = array(); @@ -299,7 +308,6 @@ class Segmenter { } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); - $segment_id = $segment_data[ $segment_dimension ]; unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_label' => $segment_labels[ $segment_id ], @@ -407,7 +415,8 @@ class Segmenter { if ( isset( $this->query_args['coupons'] ) ) { $args['include'] = $this->query_args['coupons']; } - $coupons = CouponsDataStore::get_coupons( $args ); + $coupons_store = new CouponsDataStore(); + $coupons = $coupons_store->get_coupons( $args ); $segments = wp_list_pluck( $coupons, 'ID' ); $segment_labels = wp_list_pluck( $coupons, 'post_title', 'ID' ); $segment_labels = array_map( 'wc_format_coupon_code', $segment_labels ); diff --git a/plugins/woocommerce-admin/src/API/Reports/SqlQuery.php b/plugins/woocommerce-admin/src/API/Reports/SqlQuery.php new file mode 100644 index 00000000000..cf082ccd1f8 --- /dev/null +++ b/plugins/woocommerce-admin/src/API/Reports/SqlQuery.php @@ -0,0 +1,220 @@ + array(), + 'from' => array(), + 'left_join' => array(), + 'join' => array(), + 'right_join' => array(), + 'where' => array(), + 'where_time' => array(), + 'group_by' => array(), + 'having' => array(), + 'limit' => array(), + 'order_by' => array(), + ); + /** + * SQL clause merge filters. + * + * @var array + */ + private $sql_filters = array( + 'where' => array( + 'where', + 'where_time', + ), + 'join' => array( + 'right_join', + 'join', + 'left_join', + ), + ); + /** + * Data store context used to pass to filters. + * + * @var string + */ + protected $context; + + /** + * Constructor. + * + * @param string $context Optional context passed to filters. Default empty string. + */ + public function __construct( $context = '' ) { + $this->context = $context; + } + + /** + * Add a SQL clause to be included when get_data is called. + * + * @param string $type Clause type. + * @param string $clause SQL clause. + */ + protected function add_sql_clause( $type, $clause ) { + if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) { + $this->sql_clauses[ $type ][] = $clause; + } + } + + /** + * Get SQL clause by type. + * + * @param string $type Clause type. + * @param string $handling Whether to filter the return value (filtered|unfiltered). Default unfiltered. + * + * @return string SQL clause. + */ + protected function get_sql_clause( $type, $handling = 'unfiltered' ) { + if ( ! isset( $this->sql_clauses[ $type ] ) ) { + return ''; + } + + /** + * Default to bypassing filters for clause retrieval internal to data stores. + * The filters are applied when the full SQL statement is retrieved. + */ + if ( 'unfiltered' === $handling ) { + return implode( ' ', $this->sql_clauses[ $type ] ); + } + + if ( isset( $this->sql_filters[ $type ] ) ) { + $clauses = array(); + foreach( $this->sql_filters[ $type ] as $subset ) { + $clauses = array_merge( $clauses, $this->sql_clauses[ $subset ] ); + } + } else { + $clauses = $this->sql_clauses[ $type ]; + } + + /** + * Filter SQL clauses by type and context. + * + * @param array $clauses The original arguments for the request. + * @param string $context The data store context. + */ + $clauses = apply_filters( "wc_admin_clauses_{$type}", $clauses, $this->context ); + /** + * Filter SQL clauses by type and context. + * + * @param array $clauses The original arguments for the request. + */ + $clauses = apply_filters( "wc_admin_clauses_{$type}_{$this->context}", $clauses ); + return implode( ' ', $clauses ); + } + + /** + * Clear SQL clauses by type. + * + * @param string|array $types Clause type. + */ + protected function clear_sql_clause( $types ) { + foreach ( (array) $types as $type ) { + if ( isset( $this->sql_clauses[ $type ] ) ) { + $this->sql_clauses[ $type ] = array(); + } + } + } + + /** + * Replace strings within SQL clauses by type. + * + * @param string $type Clause type. + * @param string $search String to search for. + * @param string $replace Replacement string. + */ + protected function str_replace_clause( $type, $search, $replace ) { + if ( isset( $this->sql_clauses[ $type ] ) ) { + foreach ( $this->sql_clauses[ $type ] as $key => $sql ) { + $this->sql_clauses[ $type ][ $key ] = str_replace( $search, $replace, $sql ); + } + } + } + + /** + * Get the full SQL statement. + * + * @return string + */ + public function get_query_statement() { + $join = $this->get_sql_clause( 'join', 'filtered' ); + $where = $this->get_sql_clause( 'where', 'filtered' ); + $group_by = $this->get_sql_clause( 'group_by', 'filtered' ); + $having = $this->get_sql_clause( 'having', 'filtered' ); + $order_by = $this->get_sql_clause( 'order_by', 'filtered' ); + + + $statement = " + SELECT + {$this->get_sql_clause( 'select', 'filtered' )} + FROM + {$this->get_sql_clause( 'from', 'filtered' )} + {$join} + WHERE + 1=1 + {$where} + "; + + if ( ! empty( $group_by ) ) { + $statement .= " + GROUP BY + {$group_by} + "; + if ( ! empty( $having ) ) { + $statement .= " + HAVING + 1=1 + {$having} + "; + } + } + + if ( ! empty( $order_by ) ) { + $statement .= " + ORDER BY + {$order_by} + "; + } + + return $statement . $this->get_sql_clause( 'limit', 'filtered' ); + } + + /** + * Reinitialize the clause array. + */ + public function clear_all_clauses() { + $this->sql_clauses = array( + 'select' => array(), + 'from' => array(), + 'left_join' => array(), + 'join' => array(), + 'right_join' => array(), + 'where' => array(), + 'where_time' => array(), + 'group_by' => array(), + 'having' => array(), + 'limit' => array(), + 'order_by' => array(), + ); + } +} diff --git a/plugins/woocommerce-admin/src/API/Reports/Taxes/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Taxes/DataStore.php index 91d452b862f..5980b6f0164 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Taxes/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Taxes/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use \Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** @@ -24,7 +25,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_tax_lookup'; + protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. @@ -52,32 +53,29 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query and their mapping to SQL code. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'tax_rate_id' => 'tax_rate_id', - 'name' => 'tax_rate_name as name', - 'tax_rate' => 'tax_rate', - 'country' => 'tax_rate_country as country', - 'state' => 'tax_rate_state as state', - 'priority' => 'tax_rate_priority as priority', - 'total_tax' => 'SUM(total_tax) as total_tax', - 'order_tax' => 'SUM(order_tax) as order_tax', - 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', - 'orders_count' => 'COUNT( DISTINCT ( CASE WHEN total_tax >= 0 THEN order_id END ) ) as orders_count', - ); + protected $context = 'taxes'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious columns in SQL query. - $this->report_columns['tax_rate_id'] = $table_name . '.' . $this->report_columns['tax_rate_id']; - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'tax_rate_id' => "{$table_name}.tax_rate_id", + 'name' => 'tax_rate_name as name', + 'tax_rate' => 'tax_rate', + 'country' => 'tax_rate_country as country', + 'state' => 'tax_rate_state as state', + 'priority' => 'tax_rate_priority as priority', + 'total_tax' => 'SUM(total_tax) as total_tax', + 'order_tax' => 'SUM(order_tax) as order_tax', + 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', + 'orders_count' => "COUNT( DISTINCT ( CASE WHEN total_tax >= 0 THEN {$table_name}.order_id END ) ) as orders_count", + ); } /** @@ -92,76 +90,46 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @param array $query_args Query arguments supplied by the user. * @param string $order_status_filter Order status subquery. - * @return array Array of parameters used for SQL query. */ protected function get_from_sql_params( $query_args, $order_status_filter ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - - $sql_query['from_clause'] = ''; - $sql_query['outer_from_clause'] = ''; + $table_name = self::get_db_table_name(); if ( $order_status_filter ) { - $sql_query['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; + $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); } if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { - $sql_query['outer_from_clause'] .= " JOIN {$wpdb->prefix}woocommerce_tax_rates ON default_results.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id"; + $this->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON default_results.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" ); } else { - $sql_query['from_clause'] .= " JOIN {$wpdb->prefix}woocommerce_tax_rates ON {$table_name}.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id"; + $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_tax_rates ON {$table_name}.tax_rate_id = {$wpdb->prefix}woocommerce_tax_rates.tax_rate_id" ); } - - return $sql_query; } /** * Updates the database query with parameters used for Taxes report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_tax_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_tax_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); $order_status_filter = $this->get_status_subquery( $query_args ); - $sql_query_params = array_merge( $sql_query_params, $this->get_from_sql_params( $query_args, $order_status_filter ) ); + $this->get_from_sql_params( $query_args, $order_status_filter ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { - $allowed_taxes = implode( ',', $query_args['taxes'] ); - $sql_query_params['where_clause'] .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})"; + $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); + $this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" ); } if ( $order_status_filter ) { - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; + $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } - - return $sql_query_params; - } - - /** - * Fills ORDER BY clause of SQL request based on user supplied parameters. - * - * @param array $query_args Parameters supplied by the user. - * @return array - */ - protected function get_order_by_sql_params( $query_args ) { - $sql_query['order_by_clause'] = ''; - if ( isset( $query_args['orderby'] ) ) { - $sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] ); - } - - if ( isset( $query_args['order'] ) ) { - $sql_query['order_by_clause'] .= ' ' . $query_args['order']; - } else { - $sql_query['order_by_clause'] .= ' DESC'; - } - - return $sql_query; } /** @@ -173,7 +141,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -197,6 +165,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -204,11 +174,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $sql_query_params = $this->get_sql_query_params( $query_args ); + $this->get_sql_query_params( $query_args ); + $params = $this->get_limit_params( $query_args ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $total_results = count( $query_args['taxes'] ); - $total_pages = (int) ceil( $total_results / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); $inner_selections = array( 'tax_rate_id', 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' ); $outer_selections = array( 'name', 'tax_rate', 'country', 'state', 'priority' ); @@ -217,61 +188,42 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'tax_rate_id' ), $outer_selections ); $ids_table = $this->get_ids_table( $query_args['taxes'], 'tax_rate_id' ); - $prefix = "SELECT {$join_selections} FROM ("; - $suffix = ") AS {$table_name}"; - $right_join = "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.tax_rate_id = {$table_name}.tax_rate_id"; + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( array( 'fields' => $inner_selections ) ) ); + $this->add_sql_clause( 'select', $join_selections ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.tax_rate_id = {$table_name}.tax_rate_id" + ); + + $taxes_query = $this->get_query_statement(); } else { $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - {$table_name}.tax_rate_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$table_name}.tax_rate_id - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - $selections = $this->selected_columns( $query_args ); - - $prefix = ''; - $suffix = ''; - $right_join = ''; + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $taxes_query = $this->subquery->get_query_statement(); } $tax_data = $wpdb->get_results( - "{$prefix} - SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - {$table_name}.tax_rate_id - {$suffix} - {$right_join} - {$sql_query_params['outer_from_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $taxes_query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -330,7 +282,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { foreach ( $tax_items as $tax_item ) { $result = $wpdb->replace( - $wpdb->prefix . self::TABLE_NAME, + self::get_db_table_name(), array( 'order_id' => $order->get_id(), 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), @@ -372,14 +324,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public static function sync_on_order_delete( $order_id ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - - $wpdb->query( - $wpdb->prepare( - "DELETE FROM ${table_name} WHERE order_id = %d", - $order_id - ) - ); + $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when tax's reports are removed from database. @@ -391,4 +336,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ReportsCache::invalidate(); } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + $this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Taxes/Stats/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Taxes/Stats/DataStore.php index a27c6cf52d2..d573eacc981 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Taxes/Stats/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Taxes/Stats/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Taxes\Stats\DataStore. @@ -23,7 +24,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_tax_lookup'; + protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. @@ -46,81 +47,72 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * SQL columns to select in the db query. + * Data store context used to pass to filters. * - * @var array + * @var string */ - protected $report_columns = array( - 'tax_codes' => 'COUNT(DISTINCT tax_rate_id) as tax_codes', - 'total_tax' => 'SUM(total_tax) AS total_tax', - 'order_tax' => 'SUM(order_tax) as order_tax', - 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', - 'orders_count' => 'COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN order_id END ) ) as orders_count', - ); + protected $context = 'tax_stats'; /** - * Constructor + * Assign report columns once full table name has been assigned. */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'tax_codes' => 'COUNT(DISTINCT tax_rate_id) as tax_codes', + 'total_tax' => 'SUM(total_tax) AS total_tax', + 'order_tax' => 'SUM(order_tax) as order_tax', + 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', + 'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count", + ); } /** * Updates the database query with parameters used for Taxes report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_tax_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_tax_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { - $allowed_taxes = implode( ',', $query_args['taxes'] ); - $sql_query_params['where_clause'] .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})"; + $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); + $this->interval_query->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; + $this->interval_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } - - return $sql_query_params; } /** * Updates the database query with parameters used for Taxes Stats report * * @param array $query_args Query arguments supplied by the user. - * @param array $totals_params SQL parameters for the totals query. - * @param array $intervals_params SQL parameters for the intervals query. */ - protected function update_sql_query_params( $query_args, &$totals_params, &$intervals_params ) { - global $wpdb; - - $taxes_where_clause = ''; - $taxes_from_clause = ''; - - $order_tax_lookup_table = $wpdb->prefix . self::TABLE_NAME; + protected function update_sql_query_params( $query_args ) { + $taxes_where_clause = ''; + $order_tax_lookup_table = self::get_db_table_name(); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $allowed_taxes = implode( ',', $query_args['taxes'] ); $taxes_where_clause .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})"; } - $totals_params = array_merge( $totals_params, $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ) ); - $totals_params['where_clause'] .= $taxes_where_clause; + $this->get_time_period_sql_params( $query_args, $order_tax_lookup_table ); + $this->total_query->add_sql_clause( 'where', $taxes_where_clause ); - $intervals_params = array_merge( $intervals_params, $this->get_intervals_sql_params( $query_args, $order_tax_lookup_table ) ); - $intervals_params['where_clause'] .= $taxes_where_clause; + $this->get_intervals_sql_params( $query_args, $order_tax_lookup_table ); + $this->interval_query->add_sql_clause( 'where', $taxes_where_clause ); + $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); + $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); } /** @@ -156,7 +148,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -180,6 +172,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'totals' => (object) array(), 'intervals' => (object) array(), @@ -188,81 +182,62 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $selections = $this->selected_columns( $query_args ); - $totals_query = array(); - $intervals_query = array(); - $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); + $selections = $this->selected_columns( $query_args ); + $params = $this->get_limit_params( $query_args ); + $order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; + $this->update_sql_query_params( $query_args ); + $this->interval_query->add_sql_clause( 'join', $order_stats_join ); - $db_intervals = $wpdb->get_col( - "SELECT - {$intervals_query['select_clause']} AS time_interval - FROM - {$table_name} - {$intervals_query['from_clause']} - JOIN - {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval" + $db_intervals = $wpdb->get_col( + $this->interval_query->get_query_statement() ); // WPCS: cache ok, DB call ok, unprepared SQL ok. - $db_interval_count = count( $db_intervals ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); - $total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] ); + $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } + $this->total_query->add_sql_clause( 'select', $selections ); + $this->total_query->add_sql_clause( 'join', $order_stats_join ); + $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( - "SELECT - {$selections} - FROM - {$table_name} - {$totals_query['from_clause']} - JOIN - {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id - WHERE - 1=1 - {$totals_query['where_time_clause']} - {$totals_query['where_clause']}", + $this->total_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. if ( null === $totals ) { return new \WP_Error( 'woocommerce_reports_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce-admin' ) ); } + + // @todo remove these assignements when refactoring segmenter classes to use query objects. + $totals_query = array( + 'from_clause' => $this->total_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->total_query->get_sql_clause( 'where' ), + ); + $intervals_query = array( + 'select_clause' => $this->get_sql_clause( 'select' ), + 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), + 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), + 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), + ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); - $this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name ); + $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); if ( '' !== $selections ) { - $selections = ', ' . $selections; + $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); + $intervals = $wpdb->get_results( - "SELECT - MAX({$table_name}.date_created) AS datetime_anchor, - {$intervals_query['select_clause']} AS time_interval - {$selections} - FROM - {$table_name} - {$intervals_query['from_clause']} - JOIN - {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id - WHERE - 1=1 - {$intervals_query['where_time_clause']} - {$intervals_query['where_clause']} - GROUP BY - time_interval - ORDER BY - {$intervals_query['order_by_clause']} - {$intervals_query['limit']}", + $this->interval_query->get_query_statement(), ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -280,19 +255,31 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => (int) $query_args['page'], ); - if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { + if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); - $this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); + $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); $this->create_interval_subtotals( $data->intervals ); - $this->set_cached_data( $cache_key, $data ); } - return $data; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + unset( $this->subquery ); + $this->total_query = new SqlQuery( $this->context . '_total' ); + $this->total_query->add_sql_clause( 'from', self::get_db_table_name() ); + + $this->interval_query = new SqlQuery( $this->context . '_interval' ); + $this->interval_query->add_sql_clause( 'from', self::get_db_table_name() ); + $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); + } } diff --git a/plugins/woocommerce-admin/src/API/Reports/Variations/DataStore.php b/plugins/woocommerce-admin/src/API/Reports/Variations/DataStore.php index 21ef24dc642..f3ab122d6c1 100644 --- a/plugins/woocommerce-admin/src/API/Reports/Variations/DataStore.php +++ b/plugins/woocommerce-admin/src/API/Reports/Variations/DataStore.php @@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit; use \Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use \Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use \Automattic\WooCommerce\Admin\API\Reports\TimeInterval; +use \Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Variations\DataStore. @@ -23,7 +24,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * * @var string */ - const TABLE_NAME = 'wc_order_product_lookup'; + protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. @@ -52,19 +53,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'sku' => 'strval', ); - /** - * SQL columns to select in the db query and their mapping to SQL code. - * - * @var array - */ - protected $report_columns = array( - 'product_id' => 'product_id', - 'variation_id' => 'variation_id', - 'items_sold' => 'SUM(product_qty) as items_sold', - 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', - 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', - ); - /** * Extended product attributes to include in the data. * @@ -82,72 +70,83 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { ); /** - * Constructor + * Data store context used to pass to filters. + * + * @var string */ - public function __construct() { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - // Avoid ambigious column order_id in SQL query. - $this->report_columns['orders_count'] = str_replace( 'order_id', $table_name . '.order_id', $this->report_columns['orders_count'] ); + protected $context = 'variations'; + + /** + * Assign report columns once full table name has been assigned. + */ + protected function assign_report_columns() { + $table_name = self::get_db_table_name(); + $this->report_columns = array( + 'product_id' => 'product_id', + 'variation_id' => 'variation_id', + 'items_sold' => 'SUM(product_qty) as items_sold', + 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', + 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", + ); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. - * @param string $arg_name Name of the FROM sql param. - * @return array + * @param string $arg_name Target of the JOIN sql param. */ protected function get_from_sql_params( $query_args, $arg_name ) { global $wpdb; - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; - $sql_query['from_clause'] = ''; - $sql_query['outer_from_clause'] = ''; - if ( 'sku' === $query_args['orderby'] ) { - $sql_query[ $arg_name ] .= " JOIN {$wpdb->prefix}postmeta AS postmeta ON {$order_product_lookup_table}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'"; + if ( 'sku' !== $query_args['orderby'] ) { + return; } - return $sql_query; + $table_name = self::get_db_table_name(); + $join = "JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'"; + + if ( 'inner' === $arg_name ) { + $this->subquery->add_sql_clause( 'join', $join ); + } else { + $this->add_sql_clause( 'join', $join ); + } } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. - * @return array Array of parameters used for SQL query. */ protected function get_sql_query_params( $query_args ) { global $wpdb; - $order_product_lookup_table = $wpdb->prefix . self::TABLE_NAME; + $order_product_lookup_table = self::get_db_table_name(); - $sql_query_params = $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); - $sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) ); - $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); + $this->get_time_period_sql_params( $query_args, $order_product_lookup_table ); + $this->get_limit_sql_params( $query_args ); + $this->get_order_by_sql_params( $query_args ); if ( count( $query_args['variations'] ) > 0 ) { - $sql_query_params = array_merge( $sql_query_params, $this->get_from_sql_params( $query_args, 'outer_from_clause' ) ); + $this->get_from_sql_params( $query_args, 'outer' ); } else { - $sql_query_params = array_merge( $sql_query_params, $this->get_from_sql_params( $query_args, 'from_clause' ) ); + $this->get_from_sql_params( $query_args, 'inner' ); } $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { - $sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; + $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } if ( count( $query_args['variations'] ) > 0 ) { - $allowed_variations_str = implode( ',', $query_args['variations'] ); - $sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.variation_id IN ({$allowed_variations_str})"; + $allowed_variations_str = self::get_filtered_ids( $query_args, 'variations' ); + $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$allowed_variations_str})" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { - $sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; - $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; + $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); + $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } - - return $sql_query_params; } /** @@ -158,11 +157,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { * @return string */ protected function normalize_order_by( $order_by ) { - global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; - if ( 'date' === $order_by ) { - return $table_name . '.date_created'; + return self::get_db_table_name() . '.date_created'; } if ( 'sku' === $order_by ) { return 'meta_value'; @@ -237,7 +233,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { public function get_data( $query_args ) { global $wpdb; - $table_name = $wpdb->prefix . self::TABLE_NAME; + $table_name = self::get_db_table_name(); // These defaults are only partially applied when used via REST API, as that has its own defaults. $defaults = array( @@ -263,6 +259,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { + $this->initialize_queries(); + $data = (object) array( 'data' => array(), 'total' => 0, @@ -270,78 +268,56 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { 'page_no' => 0, ); - $selections = $this->selected_columns( $query_args ); $included_products = $this->get_included_products_array( $query_args ); + $this->get_sql_query_params( $query_args ); + $params = $this->get_limit_params( $query_args ); if ( count( $included_products ) > 0 && count( $query_args['variations'] ) > 0 ) { - $sql_query_params = $this->get_sql_query_params( $query_args ); - + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); if ( 'date' === $query_args['orderby'] ) { - $selections .= ", {$table_name}.date_created"; + $this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" ); } $total_results = count( $query_args['variations'] ); - $total_pages = (int) ceil( $total_results / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $total_results / $params['per_page'] ); $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'product_id', 'variation_id' ) ); $ids_table = $this->get_ids_table( $query_args['variations'], 'variation_id', array( 'product_id' => $included_products[0] ) ); - $prefix = "SELECT {$join_selections} FROM ("; - $suffix = ") AS {$table_name}"; - $right_join = "RIGHT JOIN ( {$ids_table} ) AS default_results - ON default_results.variation_id = {$table_name}.variation_id"; - } else { - $sql_query_params = $this->get_sql_query_params( $query_args ); + $this->add_sql_clause( 'select', $join_selections ); + $this->add_sql_clause( 'from', '(' ); + $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); + $this->add_sql_clause( 'from', ") AS {$table_name}" ); + $this->add_sql_clause( + 'right_join', + "RIGHT JOIN ( {$ids_table} ) AS default_results + ON default_results.variation_id = {$table_name}.variation_id" + ); + $variations_query = $this->get_query_statement(); + } else { $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( - SELECT - product_id - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - product_id, variation_id - ) AS tt" + {$this->subquery->get_query_statement()} + ) AS tt" ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $total_results = $db_records_count; - $total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] ); + $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } - $prefix = ''; - $suffix = ''; - $right_join = ''; + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); + $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); + $variations_query = $this->subquery->get_query_statement(); } $product_data = $wpdb->get_results( - "{$prefix} - SELECT - {$selections} - FROM - {$table_name} - {$sql_query_params['from_clause']} - WHERE - 1=1 - {$sql_query_params['where_time_clause']} - {$sql_query_params['where_clause']} - GROUP BY - product_id, variation_id - {$suffix} - {$right_join} - {$sql_query_params['outer_from_clause']} - ORDER BY - {$sql_query_params['order_by_clause']} - {$sql_query_params['limit']} - ", + $variations_query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. @@ -364,4 +340,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $data; } + + /** + * Initialize query objects. + */ + protected function initialize_queries() { + $this->clear_all_clauses(); + $this->subquery = new SqlQuery( $this->context . '_subquery' ); + $this->subquery->add_sql_clause( 'select', 'product_id' ); + $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); + $this->subquery->add_sql_clause( 'group_by', 'product_id, variation_id' ); + } } diff --git a/plugins/woocommerce-admin/src/ReportCSVEmail.php b/plugins/woocommerce-admin/src/ReportCSVEmail.php index 23bc1808b34..6960b4d8722 100644 --- a/plugins/woocommerce-admin/src/ReportCSVEmail.php +++ b/plugins/woocommerce-admin/src/ReportCSVEmail.php @@ -137,7 +137,7 @@ class ReportCSVEmail extends \WC_Email { $this->download_url = $download_url; if ( isset( $this->report_labels[ $report_type ] ) ) { - $this->report_type = $this->report_labels[ $report_type ]; + $this->report_type = $this->report_labels[ $report_type ]; $this->placeholders['{report_name}'] = $this->report_type; } diff --git a/plugins/woocommerce-admin/tests/api-init.php b/plugins/woocommerce-admin/tests/api-init.php index e27ec7294f2..27c64343990 100644 --- a/plugins/woocommerce-admin/tests/api-init.php +++ b/plugins/woocommerce-admin/tests/api-init.php @@ -42,7 +42,7 @@ class WC_Tests_API_Init extends WC_REST_Unit_Test_Case { if ( 0 === strpos( $query, 'REPLACE INTO' ) && - false !== strpos( $query, OrdersStatsDataStore::TABLE_NAME ) + false !== strpos( $query, OrdersStatsDataStore::get_db_table_name() ) ) { remove_filter( 'query', array( $this, 'filter_order_query' ) ); return "DESCRIBE $wpdb->posts"; // Execute any random query. diff --git a/plugins/woocommerce-admin/tests/framework/helpers/class-wc-helper-reports.php b/plugins/woocommerce-admin/tests/framework/helpers/class-wc-helper-reports.php index afbcf0f893a..2360361f82f 100644 --- a/plugins/woocommerce-admin/tests/framework/helpers/class-wc-helper-reports.php +++ b/plugins/woocommerce-admin/tests/framework/helpers/class-wc-helper-reports.php @@ -17,10 +17,10 @@ class WC_Helper_Reports { */ public static function reset_stats_dbs() { global $wpdb; - $wpdb->query( "DELETE FROM $wpdb->prefix" . \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::TABLE_NAME ); // @codingStandardsIgnoreLine. - $wpdb->query( "DELETE FROM $wpdb->prefix" . \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore::TABLE_NAME ); // @codingStandardsIgnoreLine. - $wpdb->query( "DELETE FROM $wpdb->prefix" . \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore::TABLE_NAME ); // @codingStandardsIgnoreLine. - $wpdb->query( "DELETE FROM $wpdb->prefix" . \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::TABLE_NAME ); // @codingStandardsIgnoreLine. + $wpdb->query( 'DELETE FROM ' . \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::get_db_table_name() ); // @codingStandardsIgnoreLine. + $wpdb->query( 'DELETE FROM ' . \Automattic\WooCommerce\Admin\API\Reports\Products\DataStore::get_db_table_name() ); // @codingStandardsIgnoreLine. + $wpdb->query( 'DELETE FROM ' . \Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore::get_db_table_name() ); // @codingStandardsIgnoreLine. + $wpdb->query( 'DELETE FROM ' . \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_db_table_name() ); // @codingStandardsIgnoreLine. \Automattic\WooCommerce\Admin\CategoryLookup::instance()->regenerate(); } }