Как расширить WP_Query для включения пользовательской таблицы в запрос?
Я уже несколько дней работаю над этой проблемой. Изначально вопрос заключался в том, как хранить данные о подписчиках пользователя в базе данных, на что я получил несколько хороших рекомендаций здесь на WordPress Answers. Следуя рекомендациям, я добавил новую таблицу следующего вида:
id leader_id follower_id
1 2 4
2 3 10
3 2 10
В таблице выше, в первой строке пользователь с ID 2 отслеживается пользователем с ID 4. Во второй строке пользователь с ID 3 отслеживается пользователем с ID 10. Та же логика применяется и для третьей строки.
Теперь я хочу расширить WP_Query так, чтобы можно было ограничить выборку постов только теми, которые созданы лидером(ами) определенного пользователя. Таким образом, учитывая приведенную выше таблицу, если передать ID пользователя 10 в WP_Query, результаты должны содержать только посты пользователей с ID 2 и ID 3.
Я много искал, пытаясь найти ответ. Я не нашел ни одного учебника, который помог бы мне понять, как расширить класс WP_Query. Я видел ответы Майка Шинкеля (расширение WP_Query) на похожие вопросы, но я действительно не понял, как применить это к моим потребностям. Было бы здорово, если бы кто-нибудь мог помочь мне с этим.

Важное предупреждение: правильный способ сделать это — НЕ изменять структуру таблицы, а использовать wp_usermeta. Тогда вам не понадобится писать собственный SQL для запроса постов (хотя всё равно потребуется некоторый кастомный SQL для получения списка всех, кто подчиняется определённому руководителю — например, в разделе админки). Однако, поскольку автор вопроса спрашивал о написании кастомного SQL, вот текущие лучшие практики для вставки собственного SQL в существующий запрос WordPress.
Если вы делаете сложные JOIN-запросы, недостаточно просто использовать фильтр posts_where, потому что вам также понадобится модифицировать JOIN, SELECT и, возможно, GROUP BY или ORDER BY части запроса.
Лучший вариант — использовать фильтр 'posts_clauses'. Это очень полезный фильтр (которым не стоит злоупотреблять!), позволяющий добавлять/изменять различные части SQL-запроса, автоматически генерируемого множеством строк кода в ядре WordPress. Сигнатура функции-колбэка для этого фильтра:
function posts_clauses_filter_cb( $clauses, $query_object ){ }
, и она ожидает, что вы вернёте $clauses
.
Части запроса (Clauses)
$clauses
— это массив, содержащий следующие ключи; каждый ключ представляет собой строку SQL, которая будет напрямую использована в итоговом SQL-запросе к базе данных:
- where
- groupby
- join
- orderby
- distinct
- fields
- limits
Если вы добавляете таблицу в базу данных (делайте это только если совершенно невозможно использовать post_meta, user_meta или таксономии), вам, вероятно, понадобится изменить несколько этих частей, например, fields
(часть "SELECT" в SQL), join
(все ваши таблицы, кроме указанной в "FROM"), и, возможно, orderby
.
Изменение частей запроса
Лучший способ сделать это — использовать ссылку на соответствующий ключ массива $clauses
, полученного из фильтра:
$join = &$clauses['join'];
Теперь, если вы измените $join
, вы напрямую измените $clauses['join']
, поэтому изменения сохранятся в $clauses
, когда вы его вернёте.
Сохранение оригинальных частей запроса
Скорее всего (нет, серьёзно, слушайте внимательно) вы захотите сохранить существующий SQL, сгенерированный WordPress. Если нет, вам, вероятно, стоит посмотреть на фильтр posts_request
— это полный mySQL-запрос непосредственно перед отправкой в базу данных, так что вы можете полностью перезаписать его своим. Зачем это делать? Скорее всего, вам это не нужно.
Итак, чтобы сохранить существующий SQL в частях запроса, помните, что нужно дополнять их, а не перезаписывать (т.е. использовать $join .= ' {НОВЫЙ SQL}';
, а не $join = '{ПЕРЕЗАПИСЬ SQL}';
). Учитывайте, что каждый элемент массива $clauses
— это строка, поэтому при дополнении лучше добавлять пробел перед другими символами, иначе вы можете получить синтаксическую ошибку SQL.
Вы можете просто предполагать, что в каждой части всегда что-то есть, и начинать каждую новую строку с пробела, например: $join .= ' my_table
, или всегда добавлять пробел при необходимости:
$join = &$clauses['join'];
if (! empty( $join ) ) $join .= ' ';
$join .= "JOIN my_table... "; // <-- обратите внимание на пробел в конце
$join .= "JOIN my_other_table... ";
return $clauses;
Это скорее вопрос стиля. Главное помнить: всегда оставляйте пробел ПЕРЕД вашей строкой, если дополняете часть запроса, в которой уже есть SQL!
Собираем всё вместе
Первое правило разработки для WordPress — использовать как можно больше встроенных функций. Это лучший способ обеспечить совместимость в будущем. Предположим, команда разработчиков решит, что WordPress теперь будет использовать SQLite или Oracle или другой язык баз данных. Любой вручную написанный mySQL может стать недействительным и сломать ваш плагин или тему! Лучше позволить WordPress самому сгенерировать как можно больше SQL, а затем добавить только необходимые части.
Итак, первым делом нужно использовать WP_Query
для генерации основной части вашего запроса. Точный метод зависит от того, где должен отображаться этот список постов. Если это часть страницы (не основной запрос), используйте get_posts()
; если это основной запрос, можно использовать query_posts()
, но правильнее перехватить основной запрос до отправки в базу данных (и потребления ресурсов сервера), используя фильтр request
.
Итак, вы сгенерировали запрос, и SQL вот-вот будет создан. На самом деле, он уже создан, но ещё не отправлен в базу данных. Используя фильтр posts_clauses
, вы добавите свою таблицу отношений сотрудников в запрос. Назовём эту таблицу {$wpdb->prefix} . 'user_relationship', и это таблица связей. (Кстати, я рекомендую сделать структуру этой таблицы более универсальной, превратив её в полноценную таблицу связей с полями: 'relationship_id', 'user_id', 'related_user_id', 'relationship_type'; это гораздо гибче и мощнее... но я отвлёкся).
Если я правильно понял, вы хотите передать ID лидера и затем видеть только посты его подчинённых. Надеюсь, я правильно понял. Если нет, вам придётся адаптировать сказанное под свои нужды. Я буду придерживаться вашей структуры таблицы: у нас есть leader_id
и follower_id
. Таким образом, JOIN будет выполняться по {$wpdb->posts}.post_author
как внешнему ключу к 'follower_id' в вашей таблице 'user_relationship'.
add_filter( 'posts_clauses', 'filter_by_leader_id', 10, 2 ); // нам нужен 2, потому что мы хотим получить все аргументы
function filter_by_leader_id( $clauses, $query_object ){
// Я не знаю, как вы планируете передавать leader_id, поэтому предположим, что это глобальная переменная
global $leader_id;
// В этом примере я хочу затронуть только запрос на главной странице.
// Здесь используется $query_object, чтобы избежать влияния на ВСЕ запросы
// (поскольку ВСЕ запросы проходят через этот фильтр)
if ( $query_object->is_home() ){
// Теперь добавим вашу таблицу в SQL
$join = &$clauses['join'];
if (! empty( $join ) ) $join .= ' '; // добавляем пробел только если нужно (для пущей важности!)
$join .= "JOIN {$wpdb->prefix}employee_relationship EMP_R ON EMP_R.follower_id = {$wpdb->posts}.author_id";
// И обязательно добавим это в наши критерии выборки
$where = &$clauses['where'];
// В любом случае, вы всегда начинаете с AND, потому что в WHERE всегда есть '1=1' в начале, добавленное WP.
// Не забудьте ведущий пробел!
$where .= " AND EMP_R.leader_id={$leader_id}"; // предполагаем, что $leader_id всегда (int)
// И, предполагаю, вы хотите группировать посты по ID пользователя, так что модифицируем groupby
$groupby = &$clauses['groupby'];
// Нам нужно добавить в начало, так что...
if (! empty( $groupby ) ) $groupby = ' ' . $groupby; // Для любителей выпендриваться
$groupby = "{$wpdb->posts}.post_author" . $groupby;
}
// В любом случае, нам нужно вернуть наши части запроса...
return $clauses;
}

Я отвечаю на этот вопрос с большим опозданием и приношу свои извинения. Я был слишком занят дедлайнами, чтобы ответить раньше.
Большое спасибо @m0r7if3r и @kaiser за предоставление базовых решений, которые я смог расширить и реализовать в своем приложении. Этот ответ содержит подробности моей адаптации решений, предложенных @m0r7if3r и @kaiser.
Сначала позвольте объяснить, почему этот вопрос был задан. Из вопроса и комментариев к нему можно понять, что я пытаюсь заставить WP_Query получить записи всех пользователей (лидеров), за которыми следует данный пользователь (подписчик). Связь между подписчиком и лидером хранится в пользовательской таблице follow
. Самое распространенное решение этой проблемы - извлечь ID пользователей всех лидеров подписчика из таблицы follow и поместить их в массив. Смотрите ниже:
global $wpdb;
$results = $wpdb->get_results($wpdb->prepare('SELECT leader_id FROM cs_follow WHERE follower_id = %s', $user_id));
foreach($results as $result)
$leaders[] = $result->leader_id;
После получения массива лидеров его можно передать в качестве аргумента в WP_Query. Смотрите ниже:
if (isset($leaders)) $authors = implode(',', $leaders); // Необходимо, так как аргумент authors в WP_Query принимает только строку, содержащую ID авторов записей, разделенные запятыми
$args = array(
'post_type' => 'post',
'posts_per_page' => 10,
'author' => $authors
);
$wp_query = new WP_Query( $args );
// Продолжается обычный цикл WordPress
Вышеприведенное решение является самым простым способом достижения желаемых результатов. Однако оно не масштабируется. Как только у вас появится подписчик, следующий за десятками тысяч лидеров, результирующий массив ID лидеров станет чрезвычайно большим и заставит ваш сайт WordPress использовать 100-250 МБ памяти при каждой загрузке страницы и в конечном итоге приведет к сбою сайта. Решение проблемы заключается в том, чтобы запускать SQL-запрос непосредственно к базе данных и получать соответствующие записи. Именно тогда пришло на помощь решение @m0r7if3r. Следуя рекомендации @kaiser, я приступил к тестированию обеих реализаций. Я импортировал около 47 тысяч пользователей из CSV-файла для регистрации на свежей тестовой установке WordPress. На установке работала тема Twenty Eleven. После этого я запустил цикл for, чтобы около 50 пользователей следили за каждым другим пользователем. Разница во времени выполнения запросов для решений @kaiser и @m0r7if3r была поразительной. Решение @kaiser обычно занимало от 2 до 5 секунд на каждый запрос. Вариации, я полагаю, происходят из-за того, что WordPress кэширует запросы для последующего использования. С другой стороны, решение @m0r7if3r показало среднее время выполнения запроса 0,02 мс. При тестировании обоих решений индексирование для столбца leader_id было включено. Без индексирования наблюдалось значительное увеличение времени выполнения запроса.
Использование памяти при использовании решения на основе массива составляло около 100-150 МБ и снизилось до 20 МБ при выполнении прямого SQL-запроса.
Я столкнулся с проблемой в решении @m0r7if3r, когда мне нужно было передать ID подписчика в функцию фильтра posts_where. По крайней мере, насколько я знаю, WordPress не предоставляет средств для передачи переменной в функции фильтра. Вы можете использовать глобальные переменные, но я хотел избежать глобальных переменных. В итоге я расширил WP_Query, чтобы окончательно решить проблему. Итак, вот окончательное решение, которое я реализовал (на основе решения @m0r7if3r).
class WP_Query_Posts_by_Leader extends WP_Query {
var $follower_id;
function __construct($args=array()) {
if(!empty($args['follower_id'])) {
$this->follower_id = $args['follower_id'];
add_filter('posts_where', array($this, 'posts_where'));
}
parent::query($args);
}
function posts_where($where) {
global $wpdb;
$table_name = $wpdb->prefix . 'follow';
$where .= $wpdb->prepare(" AND post_author IN (SELECT leader_id FROM " . $table_name . " WHERE follower_id = %d )", $this->follower_id);
return $where;
}
}
$args = array(
'post_type' => 'post',
'posts_per_page' => 10,
'follower_id' => $follower_id
);
$wp_query = new WP_Query_Posts_by_Leader( $args );
Примечание: В итоге я протестировал вышеприведенное решение с 1,2 миллиона записей в таблице follow. Среднее время выполнения запроса составило около 0,060 мс.

Вы можете реализовать это полностью на SQL с помощью фильтра posts_where
. Вот пример такого решения:
if( some condition )
add_filter( 'posts_where', 'wpse50305_leader_where' );
// примечание: ID вопроса одинаково читается в обе стороны
function wpse50305_leader_where( $where ) {
$where .= $GLOBALS['wpdb']->prepare( ' AND post_author '.
'IN ( '.
'SELECT leader_id '.
'FROM custom_table_name '.
'WHERE follower_id = %s'.
' ) ', $follower_id );
return $where;
}
Я думаю, что есть способ сделать это с помощью JOIN
, но я пока не придумал как. Я продолжу экспериментировать и обновлю ответ, если найду решение.
Как альтернативу, как предложил @kaiser, вы можете разделить это на две части: получение лидеров и выполнение запроса. У меня есть ощущение, что это может быть менее эффективно, но определенно более понятный способ. Вам нужно будет самостоятельно проверить эффективность, чтобы определить какой метод лучше, так как вложенные SQL-запросы могут работать довольно медленно.
ИЗ КОММЕНТАРИЕВ:
Функцию следует разместить в файле functions.php
, а вызов add_filter()
делать непосредственно перед вызовом метода query()
в WP_Query
. Сразу после этого следует вызвать remove_filter()
, чтобы это не повлияло на другие запросы.

Отредактировал ваш вариант A и добавил prepare()
. Надеюсь, вы не против правки. И да: Производительность должен измерить сам автор вопроса. В любом случае: я всё ещё считаю, что это должно быть просто usermeta и ничего больше.

@m0r7if3r Спасибо за попытку предложить решение. Я только что оставил комментарий в ответ на ответ kaiser, с опасениями по поводу возможных проблем с масштабируемостью. Пожалуйста, примите это во внимание.

@m0r7if3r Спасибо. Наличие таких ребят, как ты, в сообществе — это просто супер :)

@m0r7if3r Тестировал это и обнаружил, что из-за этого WP_Query возвращает все записи. Под "всеми записями" я имею в виду записи всех типов (посты, вложения, страницы) всех пользователей. Вот SQL, который выводит echo $wp_query->request;
для запроса SQL_CALC_FOUND_ROWS cs_posts.* FROM cs_posts INNER JOIN cs_postmeta ON (cs_posts.ID = cs_postmeta.post_id) WHERE 1=1 GROUP BY cs_posts.ID ORDER BY cs_posts.post_date DESC LIMIT 0, 30

Я забыл добавить return $where;
. Исправляет ли обновленный код эту проблему?

Уф! Опечатки. Да, теперь работает. Попробую реализовать решение от @kaiser и замерить время выполнения. Кстати, побочный эффект, который я заметил при использовании этого метода — он начинает влиять на все запросы, если их несколько на одной странице. Кроме того, у меня сломались URL-перезаписи, когда код был помещен в function.php. Перенос кода непосредственно перед вызовом WP_Query устранил проблему с перезаписями URL. Хотя не уверен, является ли размещение кода перед WP_Query плохой практикой???

Вам следует поместить функцию в ваш functions.php
и добавить фильтр через add_filter()
непосредственно перед вызовом метода query()
в WP_Query
. Сразу после этого следует удалить фильтр через remove_filter()
, чтобы он не влиял на другие запросы. Не уверен, в чем может быть проблема с перезаписью URL — я много раз использовал posts_where
и никогда не сталкивался с этим...

@m0r7if3r Спасибо за подсказку. Продолжаю тестирование на тестовых данных. Буду держать в курсе.

@m0r7if3r Ваш последний комментарий мог бы стать отличной цитатой внутри Q :)

Теговый шаблон
Просто поместите обе функции в ваш файл functions.php
. Затем настройте первую функцию и добавьте имя вашей пользовательской таблицы. Далее вам потребуется метод проб и ошибок, чтобы исключить ID текущего пользователя из результирующего массива (см. комментарий).
/**
* Получить "Лидеров" текущего пользователя
* @param int $user_id ID текущего пользователя
* @return array $query Массив лидеров
*/
function wpse50305_get_leaders( $user_id )
{
global $wpdb;
return $wpdb->query( $wpdb->prepare(
"
SELECT `leader_id`, `follower_id`
FROM %s
WHERE `follower_id` = %s
ORDERBY `leader_id` ASC
",
// Измените имя таблицы
"{$wpdb->prefix}custom_table_name"
$user_id
) );
}
/**
* Получить массив постов, содержащих записи
* "Лидеров", на которых подписан текущий пользователь
* @return array $posts Посты от текущих "Лидеров"
*/
function wpse50305_list_posts_by_leader()
{
get_currentuserinfo();
global $current_user;
$user_id = $current_user->ID;
$leaders = wpse5035_get_leaders( $user_id );
// возможно, вам потребуется пройтись по массиву $leaders
// и исключить ID подписчиков
return get_posts( array(
'author' => implode( ",", $leaders )
) );
}
Внутри шаблона
Здесь вы можете делать с результатами все, что угодно.
foreach ( wpse50305_list_posts_by_leader() as $post )
{
// делаем что-то с $post
}
ПРИМЕЧАНИЕ У нас нет тестовых данных и т.д., поэтому приведенный выше код является в некоторой степени предположением. Убедитесь, что вы отредактируете этот ответ, указав, что сработало у вас, чтобы у нас был удовлетворительный результат для будущих читателей. Я одобрю правку, если у вас недостаточно репутации. Затем вы также можете удалить это примечание. Спасибо.

Спасибо за предоставленное решение. Однако у этого подхода есть недостатки с точки зрения масштабируемости. Проблема в получении id лидеров в виде массива. Этот массив может стать чрезвычайно большим в зависимости от количества пользователей, на которых подписан пользователь. Я видел, как это потребляет большое количество памяти. Это нужно делать одним SQL-запросом, который может включать соединение (JOIN) пользовательской таблицы и таблицы WP posts с последующей выборкой результатов. Лучшее решение — расширить WP_Query. Проблема в том, что я не знаю, как это сделать. Еще раз спасибо за попытку. Отзывы приветствуются.

JOIN
работает гораздо дороже. Плюс: Как я уже упоминал, у нас нет тестовых данных, так что протестируйте оба варианта и поделитесь результатами.

WP_Query сам по себе использует JOIN между таблицей posts и postmeta при запросах. Я видел, как использование памяти PHP увеличивалось до 70–200 МБ за одну загрузку страницы. Запуск чего-то подобного с большим количеством одновременных пользователей потребовал бы экстремальной инфраструктуры. Я предполагаю, что, поскольку WordPress уже реализует подобную технику, JOIN должны быть менее затратными по сравнению с работой с массивом ID.

Нет, извините, но нет. Также есть попытки избавиться от дополнительных JOIN
. Пример: WordPress добавляет JOIN
таблицы терминов при запросе нескольких таксономий, что снижает производительность. Просто используйте timer_start()
перед запросом и timer_stop()
после запроса, чтобы измерить разницу.

Плюс: Убедитесь, что у вас есть INDEX
на нужной колонке. Это дает огромную разницу в производительности. В одном из моих проектов, где нужно было выполнять запросы по широте/долготе, я создал дополнительную таблицу — небольшую, хорошо индексированную, и она работает гораздо быстрее, чем любой JOIN
, который я пробовал. Для более глубокого понимания работы JOIN
, прочтите это.

Ах да, это также зависит от того, какой именно JOIN
вы используете...

Значит, ваш совет — использовать массив вместо SQL-запроса с JOIN, верно?

Да, но, как сказал @m0r7if3r: Измерьте. Я дал вам ссылки. Вам просто нужно замерить время и показать нам, что у вас получилось. Если вы этого не сделаете, то мы просто хорошо пофантазировали :)

Я обязательно попробую оба решения. Но для замера времени мне нужно будет наполнить базу тестовыми данными, что займет несколько часов (добавлю в БД 50к пользователей). Сделаю и отпишусь.

Ок, вот результаты тестов. Я добавил около 47K пользователей из CSV-файла. Затем запустил цикл for, чтобы первые 45 пользователей подписались на всех остальных. В результате в моей кастомной таблице оказалось 3,704,951 записей. Изначально решение @m0r7if3r давало время запроса 95 секунд, которое сократилось до 0.020 мс после включения индексации по столбцу leader_id. Общее потребление памяти PHP составило около 20MB. С другой стороны, ваше решение занимало от 2 до 5 секунд на запрос при включенной индексации. Общее потребление памяти PHP составило около 117MB.

@John Не мог бы ты добавить это как отдельный ответ? Очень рад, что ты провел этот тест и предоставил нам реальные данные. :) Что я бы оценил еще больше - это более "реалистичный" тест: пусть каждый пользователь подписывается на $leader_amount = rand( 0, 5 );
а затем добавляет число $leader_amount
умножить на $random_ids = rand( 0, 47000 );
для каждого пользователя. Пока что мы знаем следующее: мое решение было бы крайне плохим, если пользователь подписан на каждого другого пользователя. Кроме того: тебе нужно показать как именно ты проводил тест и где именно добавлял таймеры. Это одноразовый вопрос, и мне он очень нравится :)

@John Я поделился твоим вопросом и участием и хотел отметить, что всем очень нравится твоя работа над этим (смотри апвоты). :)

Я проведу дополнительные тесты, основываясь на твоем предложении провести более реалистичное тестирование, и затем напишу ответ с деталями. Однако для настоящего реалистичного теста есть еще две переменные, которые я пока не могу смоделировать. Первая: как минимум 20 постов от каждого пользователя. Сейчас я тестировал только с около 50 постами от 2-3 пользователей. Вторая: высокая посещаемость. Эти две переменные также повлияют на результаты в реальных условиях.

Дальше изучал твое предложение рандомизировать следующее. Однако я не смог разобраться в фрагментах кода, которые ты написал. Полный сниппет был бы более полезным. Вот что я сделал, чтобы первые 50 пользователей подписались на всех остальных: for ($j = 2; $j <= 52; $j++) { for ($i = ($j + 1); $i <= 47000; $i++) { $rows_affected = $wpdb->insert($table_name, array( 'leader_id' => $i, 'follower_id' => $j)); } }
. Очень простой вложенный цикл for.

О трафике) Вы могли бы использовать последние версии Apache, Nginx, минификацию, кеширование, сжатие и т.д. Каждый из этих факторов повлиял бы на результат. Но нам нужны результаты в локальной, "чистой" среде (без плагинов, стандартная тема), которые можно действительно объективно оценить.

Вы получили уведомление о моем комментарии по поводу рандомизации? Кажется, я забыл упомянуть ваш ник в комментарии. Тем не менее, думаю, владелец поста получил уведомление. Подожду ваших замечаний перед тем как продолжить.

Примечание: Этот ответ приведен здесь, чтобы избежать длительного обсуждения в комментариях
Вот код от автора вопроса из комментариев для добавления первого набора тестовых пользователей. Его нужно модифицировать под реальный пример.
for ( $j = 2; $j <= 52; $j++ ) { for ( $i = ($j + 1); $i <= 47000; $i++ ) { $rows_affected = $wpdb->insert( $table_name, array( 'leader_id' => $i, 'follower_id' => $j ) ); } }
Автор о тесте Для этого я добавил около 47K пользователей из CSV-файла. Затем запустил цикл, чтобы первые 45 пользователей подписались на всех остальных.
- В результате в моей кастомной таблице оказалось 3,704,951 записей.
- Изначально решение @m0r7if3r давало время запроса 95 секунд, которое сократилось до 0.020 мс после включения индексации для колонки leader_id. Общий расход памяти PHP составил около 20MB.
- С другой стороны, ваше решение заняло от 2 до 5 секунд для запроса с включенной индексацией. Общий расход памяти PHP составил около 117MB.
Мой ответ на этот ↑ тест:
более "реалистичный" тест: пусть каждый пользователь подписывается на
$leader_amount = rand( 0, 5 );
лидеров, а затем добавляется количество$leader_amount x $random_ids = rand( 0, 47000 );
для каждого пользователя. Пока что мы знаем: мое решение было бы крайне плохим, если пользователь подписан на всех остальных. Кроме того: вам нужно показать, как именно вы проводили тест и где именно добавляли таймеры.Также должен отметить, что ↑ приведенное измерение времени нельзя считать точным, так как оно включает время выполнения цикла. Лучше было бы пройтись по результирующему набору ID во втором цикле.
дальнейший процесс здесь
