Edited at

WordpressでカテゴリーAのBかつC該当しており、さらにカテゴリーDのEまたはFまたはG...に該当する投稿を取得する

More than 1 year has passed since last update.

タイトルのようなカオスを前に、今まで頼りにしていたget_postsWP_Queryが完全に沈黙しているのを目の当たりにし死にそうなあなた。

wpdbが満面の笑みでこっちを見ています。


具体的な架空案件例

喫茶店紹介ブログで女性向けかつスイーツ推し喫茶店の特集ページを作ることになりました。

上記カテゴリーに該当する記事をAjaxで取得し(「もっと見る」ボタンがあって、クリックすると記事が増えていくアレ)、一覧として並べるとしましょう。

さらに追加の機能として項目の絞り込み機能を用意します。あらかじめ仕込んでおいた「地域」カスタムタクソノミー(カテゴリー、タグでも可)に設定した地域名がチェックボックスとして出力されており、二つ以上のチェックが付いている場合はどちらか片方に該当すればOK、という絞り込みをしたいとします。

ふんわりした内容から鬼のような検索条件……間違いない、これはSQL直叩き案件ですね!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!


取得する部分の実装例

Wordpess案件で複雑なデータベース操作を行うのであれば、wpdb Classを用いれば比較的安全・楽にクエリを投げることができます。

以下、上記ケースにおいてAJAX通信を通してPOSTで地域タームID(カテゴリーIDやタグIDと同義)およびページャー的概念のためのLIMIT, OFFSETが飛んでくる想定のコードです。functions.php内の関数に書くなり、冒頭のnonce検証と最後のJSON部分を辞めて普通にPHPでレンダリングするのもアリです。

  //nonceの検証(失敗したらスクリプトが停止)

check_ajax_referer('ajaxNonce', 'nonce');

//グローバルからwpdbクラスを呼び出し
global $wpdb;

//検索条件である地域カテゴリー
$categories = isset($_POST['categories']) ? (array)$_POST['categories'] : array();

//追加WHERE文の作成
$wheres[] = array;
foreach ((array)$categories as $category)
{
//そもそもint型にできない or 整数値ではなさげな値を弾く
if ( ! is_scalar($category) || ! ctype_digit((string)$category))
{
continue;
}

$wheres[] = " wp_term_taxonomy.term_taxonomy_id = ".(int)esc_sql($category)." ";
}
$where = $wheres ? '('.implode('OR', ).$wheres.')' : '';

//LIMIT, OFFSET
$limit = isset($_POST['limit']) && is_scalar($_POST['limit']) ? (int)$_POST['limit'] : 30;
$offset = isset($_POST['offset']) && is_scalar($_POST['offset']) ? (int)$_POST['offset'] : 0;

//「女性向け(スラッグ名=forWormen)」「スイーツ推し(スラッグ名=sweets)」のカテゴリーを持つ投稿のみ取得
//ここで取得する列の最大値は実際に返す列より一つ多く取得する
//なぜなら、次の記事が存在するか判定するため
if ($whereCount)
{
$query = $wpdb->prepare("
SELECT DISTINCT wp_posts.*
FROM `wp_term_taxonomy`
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE
{$where}
AND wp_term_taxonomy.taxonomy = 'area'
AND ID IN(SELECT DISTINCT wp_posts.ID
FROM `wp_terms`
LEFT JOIN `wp_term_taxonomy` ON wp_terms.term_id = wp_term_taxonomy.term_id
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE (wp_terms.slug = 'forWomen' OR wp_terms.slug = 'sweets')
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
AND wp_term_taxonomy.taxonomy = 'category'
GROUP BY wp_posts.ID
HAVING COUNT(wp_posts.ID) = 2)
LIMIT %d, %d
"
, $offset, $limit + 1);
}
else
{
$query = $wpdb->prepare("
SELECT wp_posts.*
FROM `wp_terms`
LEFT JOIN `wp_term_taxonomy` ON wp_terms.term_id = wp_term_taxonomy.term_id
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE (wp_terms.slug = 'forWomen' OR wp_terms.slug = 'sweets')
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
AND wp_term_taxonomy.taxonomy = 'category'
GROUP BY wp_posts.ID
HAVING COUNT(wp_posts.ID) = 2
LIMIT %d, %d
"
, $offset, $limit + 1);
}

//データ取得
$data = $wpdb->get_results($query, ARRAY_A);

//次の記事が存在するかを情報に加える
$isNext = isset($data[$limit]) && $data[$limit] ? TRUE : FALSE;

//上記存在判定のために余分に取っておいた列を詰める
if (isset($data[$limit]) && $data[$limit])
{
array_pop($data);
}

//jsonに加工してデータを吐き出す
print json_encode(compact('data', 'isNext'), TRUE);

こんな感じにすれば目的を達成できます。


SQLはいったいどういう意味?

途中でなっがいSQLが出てきますが、else側のほうから説明していきます。

    SELECT wp_posts.* 

FROM `wp_terms`
LEFT JOIN `wp_term_taxonomy` ON wp_terms.term_id = wp_term_taxonomy.term_id
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE (wp_terms.slug = 'forWomen' OR wp_terms.slug = 'sweets')
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
AND wp_term_taxonomy.taxonomy = 'category'
GROUP BY wp_posts.ID
HAVING COUNT(wp_posts.ID) = 2
LIMIT 0, 30

まず、Wordpressのデータベースにおいてカテゴリーと投稿の間には4つのテーブルが登場します。


  • カテゴリーやタグなどのタクソノミーにおける名前やタグを司るwp_terms

  • 「カテゴリー」「タグ」といったデフォルトの分類やカスタムタクソノミーを扱うwp_term_taxonomy

  • それら分類と投稿を繋ぐwp_term_relationships

  • そして投稿や固定ページ、メディアなどを保存するwp_posts

このSQLではcategoryという分類のタクソノミー(つまりデフォルトのカテゴリー)に属するそれぞれforWomensweetsというスラッグを持つカテゴリー両方に該当し、かつ投稿タイプが「投稿」(固定ページなどではない)で状態が「公開」の記事情報を取得します。

つまり上記全てのテーブル情報が必要になるので三回結合をかけます。

一見、外部結合なのでwp_postsの情報が空の行が混じりそうですが、NULLを許容していないwp_postspost_typepost_statusに条件を指定しているので問題ないです。

少々不思議なことをしているのがGROUP BY wp_posts.ID HAVING COUNT(wp_posts.ID) = 2の部分ですが、投稿とカテゴリーはn対nの関係なので、1列の情報に対してAND条件を適用するのではなく、複数行に対してAND条件をかけなければなりません。

その場合にはWHERE句だけではなくGROUP BY句とHAVING句を組み合わせて指定してあげます。

GROUP BY句でwp_posts側を一行に絞ったWHERE (wp_terms.slug = 'forWomen' OR wp_terms.slug = 'sweets')で両方のカテゴリー項目に該当するならば、GROUP BY wp_posts.IDでグループ化した行は2行になるはずなので、HAVING COUNT(wp_posts.ID) = 2で結果を絞り込めるのです。

ちなみに本来、SQL的にはGROUP BY句に指定した行と集約関数以外はSELECT句に使えないハズなのですが、MySQLに限っては正常なクエリとして処理されるので、ほかの環境ではマネしないでください。


そして、if側、

    SELECT DISTINCT wp_posts.* 

FROM `wp_term_taxonomy`
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE (wp_term_taxonomy.term_taxonomy_id = 1 OR wp_term_taxonomy.term_taxonomy_id = 2 OR wp_term_taxonomy.term_taxonomy_id = 3)
AND wp_term_taxonomy.taxonomy = 'area'
AND ID IN(SELECT DISTINCT wp_posts.ID
FROM `wp_terms`
LEFT JOIN `wp_term_taxonomy` ON wp_terms.term_id = wp_term_taxonomy.term_id
LEFT JOIN `wp_term_relationships` ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
LEFT JOIN `wp_posts` ON wp_term_relationships.object_id = wp_posts.ID
WHERE (wp_terms.slug = 'forWomen' OR wp_terms.slug = 'sweets')
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
AND wp_term_taxonomy.taxonomy = 'category'
AND
GROUP BY wp_posts.ID
HAVING COUNT(wp_posts.ID) = 2)
LIMIT 0, 30

こちらはelse側の内容がサブクエリとなっています。

メインのクエリでは動的に作成したタクソノミーIDによる絞り込みを行うことが目的です。

こちらは文字通りOR検索なのでGROUP BY句とHAVING句による絞り込みは必要ないのですが、「地域」というカスタムタクソノミーによる分類によって絞り込みを行うのでメインクエリでAND wp_term_taxonomy.taxonomy = 'area'という文が入ります(ここに入るスラッグのような文字列はregister_taxonomyの第一引数であるtaxonomyに設定する文字列です)。

このメインクエリではタームID(くどいですが、カテゴリーIDやタグIDと同義です)による検索を行うためwp_termsは不要で、結合は二回で済みますが検索条件がスラッグによる配列で来る場合はサブクエリーのようにwp_termsも結合してください。

以上! 疲れた! ケーキ食べたい!