<?php /** * Keeps the product category lookup table in sync with live data. */ 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.InterpolatedNotPrepared } /** * 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.InterpolatedNotPrepared } } /** * 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.InterpolatedNotPrepared } /** * 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 ) ) ); } }