Кэширование мин. и макс. цены для всех категорий продуктов (Woocomerce)
Допустим у нас есть фильтр товаров для раздела (рубрики), в котором есть фильтрация по цене товара. Для такого фильтра удобно знать минимальную и максимальную цену товара в просматриваемой рубрике товаров...
Можно получать эту цену каждый раз при генерации страницы рубрики, но это тяжелый запрос и для производительности удобнее один раз получить цены для каждой рубрики и потом просто их использовать в фильтре.
Вариантов как закэшировать мин.макс. цену можно придумать много. В этой статье показан один из них. Так сделал я, когда столкнулся с такой задачей.
Принцип действия такой
Собираются все минимальные и максимальные цены товаров для каждой рубрики (для всех возможных уровней) и для таксономии в целом. Далее все эти данные сохраняются в WP опцию. Далее, при обновлении или добавлении товара все эти данные обновляются, но не сразу, а спустя 60 секунд после обновления записи (нужно это для того чтобы можно было массово обновлять записи и не проводить эту затратную операцию при каждом обновлении), время 60 сек. по идее можно увеличить до, например, 3600 (часа).
Другой подход: можно обновлять только данные отдельных рубрик в которых находится товар при обновлении/добавлении товара. И также при этом обновлять общие данные по всей таксе. Но в этом случае, нужно будет ставить костыль, на случай если товар был в одной рубрике, а при обновлении мы рубрику поменяли, нужно будет как-то вылавливать рубрику из которой был убран товар. Я выбрал вышеописанный подход - ибо в нем нет этих недостатков.
<?php
/**
* Gathers and caches the minimum and maximum value of the specified meta-field for the specified terms of the taxonomy.
* Suitable for obtaining the minimum and maximum prices of products from categories.
*
* The code caches the minimum and maximum numerical values in the post meta-fields for each category.
* It also collects the overall minimum and maximum values for the entire taxonomy.
*
* @changelog
* 2.2 FIX: Return zero values if empty data.
* 2.1 CHG: The `init` hook was moved to the `init()` method. Minor edits.
* 2.0 CHG: The code has been rewritten to allow creating different instances of the class.
* 1.1 IMP: Refactored the code. Changed data storage from options to transient options.
* CHG: The output has been changed. The min and max values are now numerical and are in the
array [min, max], not in the form of a string 'min,max'.
*
* @ver 2.2
*/
class Kama_Minmax_Post_Meta_Values {
/** @var string The meta key name where the product price is located. */
private $meta_key;
/** @var string The name of the taxonomy. */
private $taxonomy;
/** @var string The name of the post type. */
private $post_type;
/** @var string The cache transient option name. */
private $cache_key;
/** @var int Time for which the data will be refreshed. */
private $cache_ttl;
/** @var int Time in seconds after which the script will be triggered when updating a post (product). */
private $update_timeout = 60;
public function __construct( array $args ) {
if( empty( $args['meta_key'] ) || empty( $args['taxonomy'] ) || empty( $args['post_type'] ) ){
throw new \RuntimeException( 'Required `meta_key` OR `taxonomy` parameters not specified.' );
}
$this->meta_key = $args['meta_key'];
$this->taxonomy = $args['taxonomy'];
$this->post_type = $args['post_type'];
$this->cache_ttl = (int) ( $args['cache_ttl'] ?? WEEK_IN_SECONDS );
$this->cache_key = "minmax_{$args['taxonomy']}_{$args['meta_key']}_values";
}
public function init(): void {
add_action( 'init', [ $this, 'check_update_data' ], 99 );
}
/**
* @return array Array of min-max prices for all taxonomy terms. For example:
* [
* [valid_until] => 1508719235
* [all] => [ 80, 68000 ]
* [1083] => [ 950, 7300 ]
* [1084] => [ 1990, 3970 ]
* [1085] => [ 200, 3970 ]
* [1086] => [ 2000, 3970 ]
* [1089] => [ 1990, 1990 ]
* [1090] => [ 190, 1990 ]
* [1091] => [ 1590, 1990 ]
* ]
*/
public function get_data(): array {
return ( (array) get_transient( $this->cache_key ) ) ?: [];
}
/**
* @param int|string $term_id_or_all Term id or `all` key to get minmax values for the whole taxonomy.
*
* @return int[] Min, max pair: `[ 1590, 1990 ]`. Empty array if no data.
*/
public function get_term_minmax( $term_id_or_all ): array {
return $this->get_data()[ $term_id_or_all ] ?? [ 0, 0 ];
}
public function check_update_data(): void {
add_action( "save_post_{$this->post_type}", [ $this, 'mark_data_for_update' ] );
add_action( 'deleted_post', [ $this, 'mark_data_for_update' ] );
if( time() > ( $this->get_data()['valid_until'] ?? 0 ) ){
$this->update_data();
}
}
/**
* Marks the data as outdated one minute after updating the record.
*/
public function mark_data_for_update(): void {
$minmax_data = $this->get_data();
$minmax_data['valid_until'] = time() + $this->update_timeout;
set_transient( $this->cache_key, $minmax_data );
}
/**
* Updates all minmax data at once.
*/
public function update_data(): void {
$minmax_data = [
'valid_until' => time() + $this->cache_ttl
];
$this->add_all_minmax( $minmax_data );
$this->add_terms_minmax( $minmax_data );
set_transient( $this->cache_key, $minmax_data );
}
private function add_all_minmax( & $minmax_data ): void {
global $wpdb;
$sql = str_replace( '{AND_WHERE}', '', $this->minmax_base_sql() );
$minmax = $wpdb->get_row( $sql, ARRAY_A );
$minmax_data['all'] = [ (int) $minmax['min'], (int) $minmax['max'] ] + [ 0, 0 ];
}
private function add_terms_minmax( & $minmax_data ): void {
global $wpdb;
$base_sql = $this->minmax_base_sql();
$terms_data = self::get_terms_post_ids_data( $this->taxonomy );
foreach( $terms_data as $term_id => $post_ids ){
if( empty( $post_ids ) ){
continue;
}
$IN_post_ids = implode( ',', array_map( 'intval', $post_ids ) );
$minmax = $wpdb->get_row( str_replace( '{AND_WHERE}', "AND post_id IN( $IN_post_ids )", $base_sql ), ARRAY_A );
if( array_filter( $minmax ) ){
$minmax_data[ $term_id ] = [ (int) ( $minmax['min'] ?? 0 ), (int) ( $minmax['max'] ?? 0 ) ];
}
}
}
private function minmax_base_sql(): string {
global $wpdb;
return $wpdb->prepare( "
SELECT MIN( CAST(meta_value as UNSIGNED) ) as min, MAX(CAST(meta_value as UNSIGNED)) as max
FROM $wpdb->postmeta
WHERE meta_key = %s AND meta_value > 0
{AND_WHERE}
",
$this->meta_key
);
}
/**
* Collects the IDs of all records of all categories into an array with elements like:
* [
* term_id => [ post_id, post_id, ... ],
* ...
* ]
* The list of post IDs contains posts from the current category and from all nested subcategories.
*
* @return array[] Returns empty array if no data.
*/
private static function get_terms_post_ids_data( string $taxonomy ): array {
global $wpdb;
$cats_data_sql = $wpdb->prepare( "
SELECT term_id, object_id, parent
FROM $wpdb->term_taxonomy tax
LEFT JOIN $wpdb->term_relationships rel ON (rel.term_taxonomy_id = tax.term_taxonomy_id)
WHERE taxonomy = %s
",
$taxonomy
);
$terms_data = (array) $wpdb->get_results( $cats_data_sql );
/**
* Reformat the data: where the key will be the category ID, and the value will be an object with data:
* parent and object_id - all post IDs (there can be several records in the category).
*
* Get all terms of the specified taxonomy in the format:
* [
* 123 => object {
* parent => 124
* object_id => [ 12, 13, ... ],
* child => [],
* }
* 124 => ...
* ]
*/
$new_terms_data = [];
foreach( $terms_data as $data ){
if( ! isset( $new_terms_data[ $data->term_id ] ) ){
$new_terms_data[ $data->term_id ] = (object) [
'parent' => $data->parent,
'object_id' => [],
'child' => [],
];
}
if( $data->object_id ){
$new_terms_data[ $data->term_id ]->object_id[] = $data->object_id;
}
}
$terms_data = $new_terms_data;
/**
* Collect subcategories into parent categories (in the 'child' element).
* `child` will be a PHP reference to the current category element.
* This will allow recursively traversing the multi-level nesting.
*/
foreach( $terms_data as $term_id => $data ){
if( $data->parent ){
$terms_data[ $data->parent ]->child[] = & $terms_data[ $term_id ]; // reference
}
}
/**
* Collect all record IDs of all categories into one array with elements like:
* [
* term_id => [ post_id, post_id, ... ],
* ...
* ]
* The list of post IDs contains posts from the current category and from all nested subcategories.
*/
$terms_post_ids = [];
foreach( $terms_data as $term_id => $data ){
$post_ids = [];
self::collect_post_ids_recursively( $post_ids, $data );
$terms_post_ids[ $term_id ] = array_unique( $post_ids );
}
return $terms_post_ids;
}
/**
* Recursively collects object_id into the specified collector $post_ids.
*/
private static function collect_post_ids_recursively( &$post_ids, $data ) {
if( $data->object_id ){
$post_ids = array_merge( $post_ids, (array) $data->object_id );
}
foreach( $data->child as $child_data ){
self::collect_post_ids_recursively( $post_ids, $child_data );
}
}
}
Инициализируем класс в functions.php (на хуке init, чтобы к этому моменту уже были зарегистрированы все таксономии):
global $kama_minmax; $kama_minmax = new Kama_Minmax_Post_Meta_Values( [ 'meta_key' => 'price', 'taxonomy' => 'product_cat', 'post_type' => 'product', 'cache_ttl' => DAY_IN_SECONDS, // default WEEK_IN_SECONDS ] ); $kama_minmax->init();
Получаем данные в коде:
global $kama_minmax; $kama_minmax = $my_minmax->get_data();
$minmax_data в этом случае будет содержать такие данные:
$minmax_data = [ [uptime] => 1508719235 [all] => [ 80, 68000 ] [1083] => [ 950, 7300 ] [1084] => [ 1990, 3970 ] [1085] => [ 200, 3970 ] [1086] => [ 2000, 3970 ] [1089] => [ 1990, 1990 ] [1090] => [ 190, 1990 ] [1091] => [ 1590, 1990 ] ]
Где ключ массива - это ID рубрики, а значение - это 'мин,макс' цена.
Ключ 'all' содержит значение мин и макс цены для всей таксономии.
Пример: Получим мин/макс цену для рубрики 123:
global $kama_minmax; $minmax_data = $kama_minmax->get_data(); [ $min, $max ] = $kama_minmax->get_term_minmax( 123 ); echo $min; echo $max; [ $min, $max ] = $kama_minmax->get_term_minmax( 'all' ); echo $min; echo $max;
Для проверки кода, его можно запустить, например так, через GET параметры:
global $kama_minmax;
// для тестирования
# получает и выводит данные на экран
if( isset( $_GET['get_minmax_prices_test'] ) ){
die( print_r( $kama_minmax->get_data() ) );
}
# принудительно обновляет и выводит данные на экран
if( isset( $_GET['update_minmax_prices_test'] ) ){
$kama_minmax->update_data();
die( print_r( $kama_minmax->get_data() ) );
}
Заметка: Код получился довольно сложный. Изначально я видел его проще, потому что не учел что нужно собирать ID записей по всем уровням вложенных подрубрик...
Код плохо подойдет для случаев, когда в магазине очень много товаров. Запрос собирает ID товаров из рубрики в IN() функцию MySQL, которая ограничена опцией max_allowed_packet, а также работает медленнее чем использование временной таблицы для этой цели (подробнее читайте здесь).
Я думаю для большинства магазинов такой код будет работать отлично!
Вместо заключения
Этот код подойдет не только для WooCommerce, но и для любого магазина на ВП. Суть: собрать все минимальные и максимальные цены из указанного метаполя для терминов всех уровней вложенности.