WordPress как на ладони

Кэширование мин. и макс. цены для всех категорий продуктов (Woocomerce)

Допустим у нас есть фильтр товаров для раздела (рубрики), в котором есть фильтрация по цене товара. Для такого фильтра удобно знать минимальную и максимальную цену товара в просматриваемой рубрике товаров...

Можно получать эту цену каждый раз при генерации страницы рубрики, но это тяжелый запрос и для производительности удобнее один раз получить цены для каждой рубрики и потом просто их использовать в фильтре.

Вариантов как закэшировать мин.макс. цену можно придумать много. В этой статье показан один из них. Так сделал я, когда столкнулся с такой задачей.

Принцип действия такой

Собираются все минимальные и максимальные цены товаров для каждой рубрики (для всех возможных уровней) и для таксономии в целом. Далее все эти данные сохраняются в WP опцию. Далее, при обновлении или добавлении товара все эти данные обновляются, но не сразу, а спустя 60 секунд после обновления записи (нужно это для того чтобы можно было массово обновлять записи и не проводить эту затратную операцию при каждом обновлении), время 60 сек. по идее можно увеличить до, например, 3600 (часа).

Другой подход: можно обновлять только данные отдельных рубрик в которых находится товар при обновлении/добавлении товара. И также при этом обновлять общие данные по всей таксе. Но в этом случае, нужно будет ставить костыль, на случай если товар был в одной рубрике, а при обновлении мы рубрику поменяли, нужно будет как-то вылавливать рубрику из которой был убран товар. Я выбрал вышеописанный подход - ибо в нем нет этих недостатков.

GitHub
<?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.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.1
 */
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 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 ] ?? [];
	}

	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( ! $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, но и для любого магазина на ВП. Суть: собрать все минимальные и максимальные цены из указанного метаполя для терминов всех уровней вложенности.

6 комментариев
    Войти