Category Lookup Table - Fix Category Segments (https://github.com/woocommerce/woocommerce-admin/pull/2253)

* Look table class and installer

* New table + stats

* Working reports/initial population

* Remove test

* Refactor, remove depth

* Unused table

* Update todo

* Add docblocks and reorder get_insert_sql params for consistency

* Adjust css braces

* clear hook on deactivation

* PSR-4 category lookup

* linting CSS closing brace last char on line

* initialize category lookup table in unit tests

* missed linting fixes

* revert move of run_all_pending call

* use consistent reference for term_relationships in queries
This commit is contained in:
Mike Jolley 2019-10-02 00:35:37 +01:00 committed by Paul Sealock
parent ac9f3f4680
commit edcfc161fa
10 changed files with 339 additions and 33 deletions

View File

@ -22,15 +22,13 @@ $breakpoints: 320px, 400px, 600px, 782px, 960px, 1280px, 1440px;
@media (max-width: $breakpoint) {
@content;
}
}
@else {
} @else {
@if $size == $and-larger {
$approved-value: 2;
@media (min-width: $breakpoint + 1) {
@content;
}
}
@else {
} @else {
@each $breakpoint-end in $breakpoints {
$range: $breakpoint + '-' + $breakpoint-end;
@if $size == $range {
@ -50,8 +48,7 @@ $breakpoints: 320px, 400px, 600px, 782px, 960px, 1280px, 1440px;
}
@warn 'ERROR in breakpoint( #{ $size } ) : You can only use these sizes[ #{$sizes} ] using the following syntax [ <#{ nth( $breakpoints, 1 ) } >#{ nth( $breakpoints, 1 ) } #{ nth( $breakpoints, 1 ) }-#{ nth( $breakpoints, 2 ) } ]';
}
}
@else {
} @else {
$sizes: '';
@each $breakpoint in $breakpoints {
$sizes: $sizes + ' ' + $breakpoint;

View File

@ -78,7 +78,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
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'] );
$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'] );
}
/**
@ -93,20 +94,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$sql_query_params = $this->get_time_period_sql_params( $query_args, $order_product_lookup_table );
// join wp_order_product_lookup_table with relationships and taxonomies
// @todo How to handle custom product tables?
$sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->prefix}term_relationships ON {$order_product_lookup_table}.product_id = {$wpdb->prefix}term_relationships.object_id";
$sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id";
// 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";
$included_categories = $this->get_included_categories( $query_args );
if ( $included_categories ) {
$sql_query_params['where_clause'] .= " AND {$wpdb->prefix}term_taxonomy.term_id IN ({$included_categories})";
$sql_query_params['where_clause'] .= " 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' );
} else {
$sql_query_params = $this->get_order_by_params( $query_args, $sql_query_params, 'from_clause', "{$wpdb->prefix}term_taxonomy.term_id" );
$sql_query_params = $this->get_order_by_params( $query_args, $sql_query_params, 'from_clause', "{$wpdb->wc_category_lookup}.category_tree_id" );
}
// @todo Only products in the category C or orders with products from category C (and, possibly others?).
@ -121,7 +121,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )";
}
$sql_query_params['where_clause'] .= " AND taxonomy = 'product_cat' ";
$sql_query_params['where_clause'] .= " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
return $sql_query_params;
}
@ -290,7 +290,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$categories_data = $wpdb->get_results(
"${prefix}
SELECT
{$wpdb->prefix}term_taxonomy.term_id as category_id,
{$wpdb->wc_category_lookup}.category_tree_id as category_id,
{$selections}
FROM
{$table_name}
@ -300,7 +300,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
{$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']}
GROUP BY
category_id
{$wpdb->wc_category_lookup}.category_tree_id
{$suffix}
{$right_join}
{$sql_query_params['outer_from_clause']}

View File

@ -297,10 +297,10 @@ class Segmenter extends ReportsSegmenter {
$segmenting_from = "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_id';
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );

View File

@ -138,13 +138,13 @@ class Segmenter extends ReportsSegmenter {
FROM
(
SELECT
$table_name.order_id,
$table_name.order_id,
$segmenting_groupby AS $segmenting_dimension_name,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( returning_customer ) AS returning_customer
FROM
$table_name
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
@ -228,7 +228,7 @@ class Segmenter extends ReportsSegmenter {
MAX( net_total ) as net_total,
MAX( returning_customer ) AS returning_customer
FROM
$table_name
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
@ -391,11 +391,11 @@ class Segmenter extends ReportsSegmenter {
);
$segmenting_from .= "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_id';
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );

View File

@ -182,11 +182,11 @@ class Segmenter extends ReportsSegmenter {
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
);
$segmenting_from = "
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id
LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_relationships}.term_taxonomy_id = {$wpdb->wc_category_lookup}.category_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_id';
$segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL";
$segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id";
$segmenting_dimension_name = 'category_id';
// Restrict our search space for category comparisons.

View File

@ -0,0 +1,279 @@
<?php
/**
* Keeps the product category lookup table in sync with live data.
*
* @package WooCommerce Admin/Classes
*/
namespace Automattic\WooCommerce\Admin;
defined( 'ABSPATH' ) || exit;
/**
* \Automattic\WooCommerce\Admin\CategoryLookup class.
*/
class CategoryLookup {
/**
* Stores changes to categories we need to sync.
*
* @var array
*/
protected $edited_product_cats = array();
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Init hooks.
*/
public function init() {
add_action( 'generate_category_lookup_table', array( $this, 'regenerate' ) );
add_action( 'edit_product_cat', array( $this, 'before_edit' ), 99 );
add_action( 'edited_product_cat', array( $this, 'on_edit' ), 99 );
}
/**
* Regenerate all lookup table data.
*/
public function regenerate() {
global $wpdb;
// Delete existing data and ensure schema is current.
Install::create_tables();
$wpdb->query( "TRUNCATE TABLE $wpdb->wc_category_lookup" );
$terms = get_terms(
'product_cat',
array(
'hide_empty' => false,
'fields' => 'id=>parent',
)
);
$hierarchy = array();
$inserts = array();
$this->unflatten_terms( $hierarchy, $terms, 0 );
$this->get_term_insert_values( $inserts, $hierarchy );
if ( ! $inserts ) {
return;
}
$insert_string = implode(
'),(',
array_map(
function( $item ) {
return implode( ',', $item );
},
$inserts
)
);
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_tree_id,category_id) VALUES ({$insert_string})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Store edits so we know when the parent ID changes.
*
* @param int $category_id Term ID being edited.
*/
public function before_edit( $category_id ) {
$category = get_term( $category_id, 'product_cat' );
$this->edited_product_cats[ $category_id ] = $category->parent;
}
/**
* When a product category gets edited, see if we need to sync the table.
*
* @param int $category_id Term ID being edited.
*/
public function on_edit( $category_id ) {
global $wpdb;
if ( ! isset( $this->edited_product_cats[ $category_id ] ) ) {
return;
}
$category_object = get_term( $category_id, 'product_cat' );
$prev_parent = $this->edited_product_cats[ $category_id ];
$new_parent = $category_object->parent;
// No edits - no need to modify relationships.
if ( $prev_parent === $new_parent ) {
return;
}
$this->delete( $category_id, $prev_parent );
$this->update( $category_id );
}
/**
* Delete lookup table data from a tree.
*
* @param int $category_id Category ID to delete.
* @param int $category_tree_id Tree to delete from.
* @return void
*/
protected function delete( $category_id, $category_tree_id ) {
global $wpdb;
if ( ! $category_tree_id ) {
return;
}
$ancestors = get_ancestors( $category_tree_id, 'product_cat', 'taxonomy' );
$ancestors[] = $category_tree_id;
$children = get_term_children( $category_id, 'product_cat' );
$children[] = $category_id;
$id_list = implode( ',', array_map( 'intval', array_unique( array_filter( $children ) ) ) );
foreach ( $ancestors as $ancestor ) {
$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d AND category_id IN ({$id_list})", $ancestor ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
}
}
/**
* Updates lookup table data for a category by ID.
*
* @param int $category_id Category ID to update.
*/
protected function update( $category_id ) {
global $wpdb;
$ancestors = get_ancestors( $category_id, 'product_cat', 'taxonomy' );
$children = get_term_children( $category_id, 'product_cat' );
$inserts = array();
$inserts[] = $this->get_insert_sql( $category_id, $category_id );
foreach ( $ancestors as $ancestor ) {
$inserts[] = $this->get_insert_sql( $category_id, $ancestor );
foreach ( $children as $child ) {
$inserts[] = $this->get_insert_sql( $child->category_id, $ancestor );
}
}
$insert_string = implode( ',', $inserts );
$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_id, category_tree_id) VALUES {$insert_string}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Get category lookup table values to insert.
*
* @param int $category_id Category ID to insert.
* @param int $category_tree_id Tree to insert into.
* @return string
*/
protected function get_insert_sql( $category_id, $category_tree_id ) {
global $wpdb;
return $wpdb->prepare( '(%d,%d)', $category_id, $category_tree_id );
}
/**
* Used to construct insert query recursively.
*
* @param array $inserts Array of data to insert.
* @param array $terms Terms to insert.
* @param array $parents Parent IDs the terms belong to.
*/
protected function get_term_insert_values( &$inserts, $terms, $parents = array() ) {
foreach ( $terms as $term ) {
$insert_parents = array_merge( array( $term['term_id'] ), $parents );
foreach ( $insert_parents as $parent ) {
$inserts[] = array(
$parent,
$term['term_id'],
);
}
$this->get_term_insert_values( $inserts, $term['descendants'], $insert_parents );
}
}
/**
* Convert flat terms array into nested array.
*
* @param array $hierarchy Array to put terms into.
* @param array $terms Array of terms (id=>parent).
* @param integer $parent Parent ID.
*/
protected function unflatten_terms( &$hierarchy, &$terms, $parent = 0 ) {
foreach ( $terms as $term_id => $parent_id ) {
if ( (int) $parent_id === $parent ) {
$hierarchy[ $term_id ] = array(
'term_id' => $term_id,
'descendants' => array(),
);
unset( $terms[ $term_id ] );
}
}
foreach ( $hierarchy as $term_id => $terms_array ) {
$this->unflatten_terms( $hierarchy[ $term_id ]['descendants'], $terms, $term_id );
}
}
/**
* Get category descendants.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_descendants( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_id FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d",
$category_id
)
)
);
}
/**
* Return all ancestor category ids for a category.
*
* @param int $category_id The category ID to lookup.
* @return array
*/
protected function get_ancestors( $category_id ) {
global $wpdb;
return wp_parse_id_list(
$wpdb->get_col(
$wpdb->prepare(
"SELECT category_tree_id FROM $wpdb->wc_category_lookup WHERE category_id = %d",
$category_id
)
)
);
}
}

View File

@ -86,6 +86,7 @@ class FeaturePlugin {
ReportsSync::clear_queued_actions();
WC_Admin_Notes::clear_queued_actions();
wp_clear_scheduled_hook( 'wc_admin_daily' );
wp_clear_scheduled_hook( 'generate_category_lookup_table' );
}
/**
@ -145,6 +146,9 @@ class FeaturePlugin {
// CRUD classes.
WC_Admin_Notes::init();
// Initialize category lookup.
CategoryLookup::instance()->init();
// Admin note providers.
// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
new WC_Admin_Notes_Woo_Subscriptions_Notes();

View File

@ -197,6 +197,11 @@ class Install {
UNIQUE KEY user_id (user_id),
KEY email (email)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_category_lookup (
category_tree_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (category_tree_id,category_id)
) $collate;
";
return $tables;
@ -228,6 +233,7 @@ class Install {
"{$wpdb->prefix}wc_admin_notes",
"{$wpdb->prefix}wc_admin_note_actions",
"{$wpdb->prefix}wc_customer_lookup",
"{$wpdb->prefix}wc_category_lookup",
);
}
@ -270,6 +276,7 @@ class Install {
if ( ! wp_next_scheduled( 'wc_admin_daily' ) ) {
wp_schedule_event( time(), 'daily', 'wc_admin_daily' );
}
wp_schedule_single_event( time() + 10, 'generate_category_lookup_table' );
}
/**

View File

@ -48,6 +48,7 @@ class Loader {
* Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
*/
public function __construct() {
add_action( 'init', array( __CLASS__, 'define_tables' ) );
add_action( 'init', array( __CLASS__, 'load_features' ) );
; add_action( 'admin_enqueue_scripts', array( __CLASS__, 'register_scripts' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'inject_wc_settings_dependencies' ), 14 );
@ -77,6 +78,23 @@ class Loader {
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
}
/**
* Add custom tables to $wpdb object.
*/
public static function define_tables() {
global $wpdb;
// List of tables without prefixes.
$tables = array(
'wc_category_lookup' => 'wc_category_lookup',
);
foreach ( $tables as $name => $table ) {
$wpdb->$name = $wpdb->prefix . $table;
$wpdb->tables[] = $table;
}
}
/**
* Gets an array of enabled WooCommerce Admin features/sections.
*
@ -344,7 +362,7 @@ class Loader {
/**
* Returns true if we are on a "classic" (non JS app) powered admin page.
*
* @todo See usage in `admin.php`. This needs refactored and implemented properly in core.
* TODO: See usage in `admin.php`. This needs refactored and implemented properly in core.
*/
public static function is_embed_page() {
return wc_admin_is_connected_page();

View File

@ -21,5 +21,6 @@ class WC_Helper_Reports {
$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.
\Automattic\WooCommerce\Admin\CategoryLookup::instance()->regenerate();
}
}