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

タイトルのようなカオスを前に、今まで頼りにしていた[get_posts](https://wpdocs.osdn.jp/%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%82%BF%E3%82%B0/get_posts)や[WP_Query](https://wpdocs.osdn.jp/%E9%96%A2%E6%95%B0%E3%83%AA%E3%83%95%E3%82%A1%E3%83%AC%E3%83%B3%E3%82%B9/WP_Query)が完全に沈黙しているのを目の当たりにし死にそうなあなた。

[wpdb](https://wpdocs.osdn.jp/%E9%96%A2%E6%95%B0%E3%83%AA%E3%83%95%E3%82%A1%E3%83%AC%E3%83%B3%E3%82%B9/wpdb_Class)が満面の笑みでこっちを見ています。

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

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

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

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

#取得する部分の実装例
Wordpess案件で複雑なデータベース操作を行うのであれば、__wpdb Class__を用いれば比較的安全・楽にクエリを投げることができます。

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

```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側のほうから説明していきます。

```sql
    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```という分類のタクソノミー（つまりデフォルトのカテゴリー）に属するそれぞれ```forWomen```、```sweets```というスラッグを持つカテゴリー両方に該当し、かつ投稿タイプが「投稿」（固定ページなどではない）で状態が「公開」の記事情報を取得します。

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

一見、外部結合なので__wp_posts__の情報が空の行が混じりそうですが、NULLを許容していない__wp_posts__の__post_type__や__post_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に限っては正常なクエリとして処理される](https://dev.mysql.com/doc/refman/5.6/ja/group-by-handling.html)ので、ほかの環境ではマネしないでください。

---

そして、if側、

```sql
    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](http://wpdocs.osdn.jp/%E9%96%A2%E6%95%B0%E3%83%AA%E3%83%95%E3%82%A1%E3%83%AC%E3%83%B3%E3%82%B9/register_taxonomy)の第一引数である__taxonomy__に設定する文字列です）。

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

__以上！　疲れた！　ケーキ食べたい！__