2018年WordPress魔改造TOP3

過剰なカスタマイズを施したものを、我々は時にこう呼びます。「魔改造」。この字面に溢れる厨二感……。

こんにちは、バックエンドエンジニアのリョウタです。

WordPress系のイベントに登壇する際のスライドにも書いているんですけど、**WordPressの魔改造が大好き**なんですよね。
「これやるんだったらスクラッチで作ればいいじゃん」って言われそうなものを、半ば無理やりにでもWordPressで実装するのが楽しいんです。わかりますかね？この気持ち。
今年も今年とて、新規・リニューアル・保守とさまざまなWordPress案件に関わってきましたが、その中でも特に記憶に残った魔改造３つを順に振り返っていこうと思います。

# 第３位：特定のテンプレートだけ別テーマを使いたい
予算や納期の兼ね合いで、段階的なリリースになる案件はままあります。

新規制作案件の場合、リリース後に待つフェーズは、「ページや機能の単純な追加」になることが多いのであまり問題になりませんが、リニューアル案件で「過去のデータ・仕様を引き継いだ上で段階リリース」となると、どこからともなく「魔」の臭いが立ち込めてきます。

さて、今回の要件です。

* リニューアル前のテーマは商用テーマの更に子テーマ。リニューアル後も一部のテンプレートはこのテーマを使う
* リニューアル前のテーマにはウィジェットの設定やプラグインと連携する機能が含まれているようだ
* iPhoneのWordPressのアプリを使って更新することもある為、アプリからの新テーマでのプレビューもしたい
* 年別・月別アーカイブや、カテゴリー、タグアーカイブなども対象

**闇属性確定。**
**ワクワクしますね。**

##「templateSwitcher」プラグインを作ってみました
テーマが読み込まれる前に分岐しなくてはならないため、諸々の処理はプラグインを作ってその中で行います。

```php

class templateSwitcher
{
    public $templateName = '';

    public function __construct($templateName)
    {
        //引数は切り替えるテーマのディレクトリ名
        $this->templateName = $templateName;

        //プラグインのロード時に呼び出す
        add_action('plugins_loaded', array($this, 'template_switcher_filter_hooks'));

        //共通で読み込むファイル
        foreach (glob(__DIR__ . '/lib/*.php') as $file) {
            require_once $file;
        }
    }

    public function template_switcher_filter_hooks()
    {
        if (!is_admin()) {
            //テーマ切り替えのフィルターフックはこれ
            add_filter('pre_option_template', array($this, 'template_switcher'));
            add_filter('pre_option_stylesheet', array($this, 'template_switcher'));
        }
    }

    public function template_switcher($array)
    {
        //対象の固定ページを指定
        $pages = array(
            '/', //トップページ,
            '/about/', //固定ページ
            '/about/history/', //固定ページ
        );
		
        //対象のリライトルールを指定。
        $rules = array(
            '([0-9]{4})/?$', //年別アーカイブ
            '([0-9]{4})/page/?([0-9]{1,})/?$',
            '([0-9]{4})/([0-9]{1,2})/?$', //月別アーカイブ
            '([0-9]{4})/([0-9]{1,2})/page/?([0-9]{1,})/?$',
            'category/(.+?)/?$', //カテゴリーアーカイブ
            'category/(.+?)/page/?([0-9]{1,})/?$',
            'tag/([^/]+)/?$', //タグ別アーカイブ
            'tag/([^/]+)/page/?([0-9]{1,})/?$',
            '([0-9]{4})/([0-9]{1,2})/([^/]+)(?:/([0-9]+))?/?$', //詳細ページ（パーマリンク設定が /%year%/%monthnum%/%postname%/ の場合
        );

        //$rulesを繋げて、正規表現のパターンを作成
        $pattern = '/(^\/' . str_replace('/', '\/', implode(')|(^/', $rules)) . ')/';

        if (
            in_array($_SERVER['REQUEST_URI'], $pages, true) || //固定ページ
            preg_match($pattern, $_SERVER['REQUEST_URI']) || //リライトルール
            preg_match('/wp-iphone/', $_SERVER['HTTP_USER_AGENT']) || //iPhoneアプリプレビュー用
            preg_match('/.*\?.*preview=(true|1)(&.*$|$)/', $_SERVER['REQUEST_URI']) //プレビュー用
        ) {
            //TRUEならテーマを切り替える
            return $this->templateName;
        } else {
            return $array;
        }
    }
}

if (!isset($templateSwicher)) {
    //テーマを読みに行く前に処理しなければならないので、ここでnewする。
    $templateSwicher = new templateSwitcher('newthemename');
}
```

##解説
1. コンストラクタから’plugins_loaded’のアクションフックでtemplate_switcher_filter_hooks()を実行します。
WordPressはコアファイル→プラグイン→テーマの順にロードされます。
しかし、テーマ内から全体的にフックできるように設計されているため、プラグインロード完了（plugin_loaded）からテーマを読みに行くまで(setup_theme)の間に用意されているフックは多くありません。
‘plugin_loaded’時点でis_admin()は使用できますので、管理画面だった場合の分岐はここで行います。
両テーマ共通の処理もここで読み込みます。

2. template_switcher()を実行します。
フィルターフックは’pre_option_template’と’pre_option_stylesheet’です。
pre_option_(option)系のフィルターはoptionsの値をフィルタリングします。

3. テーマ切り替えのための条件を\$pagesと\$rulesに指定します。
残念ながらこの段階で、is_page()や、is_post_type_archive()、is_tax()などの関数を使うことはできません。
ですので、rewrite_ruleや、URLを指定して、\$_SERVER[‘REQUEST_URI’]と比較します。
アプリからのアクセスもここで\$_SERVER[‘HTTP_USER_AGENT’]を見て判定します。iPhoneのWordPressアプリからのアクセスの場合、wp-iphoneという文字列を含むUAになるようです。


# 第２位：カスタム投稿アーカイブページのメインクエリを「イベントの開催までは先頭に昇順、イベント開催後はその後に降順で並べる。」
何のことやら要約では伝わりにくですが、「メインクエリに分岐点を作り、前後でorderbyのルールと、orderの順序を変えたい。」という事になります。

## 仕様・要件
* 対象はカスタムフィールドにdateとtimeを持つ、イベント系投稿タイプのアーカイブページ（運用中の追加機能実装でしたので、dateとtimeを統合するのは諦めました・・・。）
* その投稿タイプにはカスタムタクソノミーもあり、タクソノミーアーカイブページも対象
* イベントの開催日時に至っていない（未開催）分を先頭に、昇順（現在の日時から未来に向かって）に並べる
* 未開催のイベントの後は、開催済みのイベントを降順（現在の日時から過去に向かって）に並べる
* アーカイブページは１ページあたりの表示件数（posts_per_page）があり、ページャーを有する

##アプローチ・実践
まずもって、クエリ１つで要件を満たすことはできないであろうと考えました。（もし方法があるようでしたら、コソッと教えて下さい）
またページャーなどもあるので、メインクエリに集約してしまう。
当該テーマでは、テンプレートが投稿タイプアーカイブもタクソノミーアーカイブも、他の投稿タイプのアーカイブも共通したarchive.phpを使用しているため、メインクエリにまとめられればテンプレートを新たに作る必要もありません。

以上のことから、

* 「ソートがより単純」、「数が多い」を基準として、主体となるクエリは開催後のもを採択する
* 開催前の投稿は、先に昇順でIDを取得する関数を用意する
* orderbyを変更するフックで1と2をガッチャンコする

というアプローチで解決する事にしました。

```php
function custom_pre_get_posts($query)
{
  //管理画面のメインクエリーとメインクエリーじゃないときは処理しない
  if (is_admin() || !$query->is_main_query()) {
    return;
  }

  if (is_post_type_archive('event') || is_tax('event-category')) {
    //開催後の開催日時に対して降順のメインクエリを作る
    $query->set('meta_query', array(
      'event_date' => array(
        'key' => 'date',
        'type' => 'DATE',
      ),
      'event_time' => array(
        'key' => 'time',
        'type' => 'TIME',
      ),
    ));
    $query->set('orderby', array(
      'event_date' => 'DESC', //まずは日付順
      'event_time' => 'DESC', //続いて時間順
      'post_date' => 'DESC', //日時が同じ場合は投稿日時順
    ));
  }
}

add_action('pre_get_posts', 'custom_pre_get_posts');

//未開催のイベントを昇順に取得。
function get_recents_event()
{
  global $wpdb;

  //ログイン時は非公開のpost_statusも含む
  $logged_in = (is_user_logged_in()) ? " OR " . $wpdb->posts . ".post_status = 'private'" : "";

  $posts = $wpdb->posts; //postsのテーブル
  $postmeta = $wpdb->postmeta; //postmetaのテーブル

  //未開催のイベントを昇順に取得するSQL文。ORDER BYは日付＞時間>投稿日時の順。（実際のクエリをコピーしてきただけだけども・・・。
  $sql = "
SELECT " . $posts . ".ID
FROM " . $posts . " 
INNER JOIN " . $postmeta . "
ON ( " . $posts . ".ID = " . $postmeta . ".post_id ) 
INNER JOIN " . $postmeta . " AS mt1
ON ( " . $posts . ".ID = mt1.post_id ) 
INNER JOIN " . $postmeta . " AS mt2
ON ( " . $posts . ".ID = mt2.post_id ) 
INNER JOIN " . $postmeta . " AS mt3
ON ( " . $posts . ".ID = mt3.post_id ) 
INNER JOIN " . $postmeta . " AS mt4
ON ( " . $posts . ".ID = mt4.post_id )
WHERE 1=1 
AND ( " . $postmeta . ".meta_key = 'date' 
AND mt1.meta_key = 'time' 
AND ( ( mt2.meta_key = 'date'
AND CAST(mt2.meta_value AS DATE) > '" . date_i18n('Y/m/d') . "' ) 
OR ( ( mt3.meta_key = 'date'
AND CAST(mt3.meta_value AS DATE) = '" . date_i18n('Y/m/d') . "' ) 
AND ( mt4.meta_key = 'time'
AND CAST(mt4.meta_value AS TIME) > '" . date_i18n('H:i:s') . "' ) ) ) )
AND " . $posts . ".post_type = 'event'
AND (" . $posts . ".post_status = 'publish'" . $logged_in . ")
GROUP BY " . $posts . ".ID
ORDER BY CAST(" . $postmeta . ".meta_value AS DATE) DESC, CAST(mt1.meta_value AS TIME) DESC, " . $posts . ".post_date DESC
  ";

  //未開催のイベントを取得
  $recents = $wpdb->get_results($sql);

  $recent_ids = array();

  //投稿IDを配列に入れる
  if (!empty($recents)) foreach ($recents as $k => $v) $recent_ids[] = $v->ID;

  return $recent_ids;
}

//orderbyを変更する
function custom_orderby($orderby)
{
  if (!is_admin() && is_main_query() && (is_post_type_archive('event') || is_tax('event-category'))) {
    global $wpdb;
    //未開催イベントの投稿ID取得
    $recents = get_recents_event();

    //orderbyに未開催イベントのIDを先頭に結合させる
    $orderby = (!empty($recents)) ? 'FIELD( ' . $wpdb->posts . '.ID, ' . implode(', ', $recents) . ' ) DESC , ' . $orderby : $orderby;
  }
  return $orderby;
}

add_filter('posts_orderby', 'custom_orderby');

```

## 解説
1. ‘pre_get_posts’で投稿タイプ’event’とカスタムタクソノミー’event-category’のクエリを編集します。<br>
メインとなるクエリのルールは「イベント開催日時の降順」です。
2. ‘posts_orderby’でメインクエリのORDER句を編集します。<br>
get_recents_event()で、未開催のイベントの投稿IDを「イベント開催日時の昇順」に取得したものを$recentsに代入します。<br>
未開催の記事があった場合、FIELD ( ‘投稿データテーブル,{投稿IDを半角カンマ区切り}’)を、既存のORDERBY（開催日時の降順）の前に結合させ、明示的に未開催の投稿を結果の先頭に持ってきます。

**そして、栄えある第一位は・・・・！**
# 第１位：よくある不動産サイトを作りたい
栄えある魔改造３部作の頂点をとったのは一つの投稿にフラグを100個くらい持たせるこの魔改造です。

この話がバックエンドユニットに上がってきた時、あたり一面に魔改造の香ばしさが立ち込めました。
例にならって当然、「WordPressでやるって縛り外せない？」という話になる訳ですが、私は内心「このまま俺に魔改造をさせてくれ・・・」と願っていました。

## 仕様・要件
* 「バストイレ別」、「独立洗面台」、「駐輪場あり」、などで絞り込める
* 価格の検索は「共益費込み」のオプションがある
* 「エリア」、「路線」のタクソノミーを持たせる
* 価格や面積で並び替えができる

## アプローチ・実践
* 管理画面のUIを作るのは手間なので、カスタムフィールド系のプラグインを使用する（今回はACF）
* 機能をプラグイン化する
* ソートを軽くするためにフラグと価格、面積などを持ったテーブルを作成する
* GETパラメーターで検索条件が渡ってくるので、各キーを’add_rewrite_tag’でパラメーターに追加する。渡ってきたキーはデータ型でフィルタする

```php
Class Realestates
{
    //テーブル名
    public $search_table = 'realestates';

    //データ構造
    public $data_structure = array(
        'no' => 'int', //物件NO
        'wanted' => 'bool', //募集中フラグ
        'type' => 'int', //物件タイプ 0:アパート 1:マンション 2:一軒家
        'floor_type' => 'int', //間取り 0:1R 1:1K 2:1DK 3:1LDK 4:2K 5:2DK 6:2LDK.......>>5LDK以上
        'price' => 'int', //価格
        'price_common' => 'int', //共益費
        'floor_space' => 'int',  //面積
        'built_year' => 'date', //築年月日
        'status' => 'int', //0:中古 1:新築 2:リフォーム済み
        'c1' => 'bool', //バストイレ別
        'c2' => 'bool', //独立洗面台
        'c3' => 'bool', //室内洗濯機置場
        'c4' => 'bool', //追い焚き
        'c5' => 'bool', //駐輪場
        'c6' => 'bool', //バイク置場
        'c7' => 'bool', //フリーレント

        /**
         * その他諸々
         */
    );

    /**
     * コンストラクタ
     */
    public function __construct()
    {
        //検索用テーブル名にプリフィックスをつける
        global $table_prefix;
        $this->search_table = $table_prefix . $this->search_table;

        //ストラクチャ追加
        add_action('init', array($this, 'add_search_structure'), 10, 0);

        if (is_admin()) {
            //投稿セーブ時に検索用テーブルに登録する
            add_action('acf/save_post', array($this, 'save_meta_data'), 20);
        } else {
            //表示項目数
            add_action('pre_get_posts', array($this, 'pre_get_posts'));
            //テーブル結合
            add_filter('posts_join', array($this, 'posts_join'));
            //ソート順変更
            add_filter('posts_orderby', array($this, 'posts_orderby'));
            //条件指定
            add_filter('posts_where', array($this, 'posts_where'));
        }
    }

    /**
     * ストラクチャ追加
     */
    public
    function add_search_structure()
    {
        foreach ($this->data_structure as $k => $v) {
            add_rewrite_tag('%' . $k . '%', '([^&]+)');
        }
        add_rewrite_tag('%area%', '([^&]+)'); //エリアタクソノミー
        add_rewrite_tag('%wayside%', '([^&]+)'); //路線タクソノミー
        add_rewrite_tag('%include_common%', '([^&]+)'); //共益費込み
        add_rewrite_tag('%sort%', '([^&]+)'); //並び順
        foreach (array('price', 'floor_space') as $v) {
            add_rewrite_tag('%max_' . $v . '%', '([^&]+)');
            add_rewrite_tag('%min_' . $v . '%', '([^&]+)');
        }
    }

    /**
     * 投稿セーブ時に検索用テーブルに登録する
     */
    public
    function save_meta_data($id)
    {
        $post = get_post($id);

        if (!empty($post) && $post->post_type == 'realestate') { //投稿タイプがrealestateの場合


            //DB接続オブジェクト
            global $wpdb;


            //DB登録用データ配列
            $data = array(
                'post_id' => $id,
                'post_type' => 'realestate',
            );

            //DB登録用にカスタムフィールドをフィルタリングしたものをセット
            $data = array_merge($data, $this->filter_meta($post->ID));

            //レコード存在チェック
            $exist_res = $wpdb->get_var("SELECT COUNT(*) FROM " . $this->search_table . " WHERE post_id = " . $id);
            if ($exist_res === "0") {
                //レコードがなければINSERT
                $res = $wpdb->insert(
                    $this->search_table,
                    $data
                );
            } else {
                //レコードがあればUPDATE
                $res = $wpdb->update(
                    $this->search_table,
                    $data,
                    array('post_id' => $post->ID)
                );
            }
            if (!$res) {
                //失敗時の処理
            }
        }
    }

    /**
     * セーブ時のデータフィルタリング
     */
    public function filter_meta($id)
    {
        //カスタムフィールド取得
        $metas = get_fields($id, false);

        $data = array();

        //タイプによってフィルターする
        foreach ($this->data_structure as $k => $type) {
            $v = (!empty($metas[$k])) ? $metas[$k] : '';
            if (empty($v)) {
                switch ($type) {
                    case 'int':
                        $v = 0;
                        break;
                    case 'text':
                        $v = '';
                        break;
                    case 'date':
                        $v = '10000101';
                        break;
                    case 'double':
                        $v = 0;
                        break;
                    case 'serialize':
                        $v = '';
                        break;
                    case 'bool':
                        $v = false;
                        break;
                }
            } else {
                switch ($type) {
                    case 'int':
                        $v = (int)$v;
                        break;
                    case 'text':
                    case 'date':
                        break;
                    case 'double':
                        $v = (float)$v;
                        break;
                    case 'serialize':
                        $v = serialize($v);
                        break;
                    case 'bool':
                        $v = true;
                        break;
                }
            }
            $data[$k] = $v;
        }

        return $data;
    }


    /**
     * タクソノミーセット
     */
    public
    function pre_get_posts($query)
    {
        #管理画面とメインクエリ以外はreturn
        if (!$query->is_main_query() && is_admin()) return;

        /**
         * タクソノミー
         */
        $tax_query = array();
        foreach (array('wayside', 'area') as $taxonomy) {
            if (!empty(get_query_var($taxonomy))) {
                $ids = array();
                foreach (get_query_var($taxonomy) as $k => $v) {
                    if (is_numeric($k) && $v === '1') $ids[] = $k;
                }
                $tax_query[] = array(
                    'taxonomy' => $taxonomy,
                    'field' => 'id',
                    'operator' => 'IN',
                    'terms' => $ids,
                );
            }
        }
        if (count($tax_query) > 1) $tax_query['relation'] = 'AND';

        //タクソノミーが指定されている場合は、tax_queryを追加する
        if (!empty($tax_query)) $query->set('tax_query', $tax_query);
    }

    public
    function posts_join($join)
    {
        global $wpdb;
        //検索用テーブルをLEFT JOINする
        $join .= ' LEFT JOIN ' . $this->search_table . ' ON ' . $wpdb->posts . '.ID = ' . $this->search_table . '.post_id ';
        return $join;
    }

    /**
     * オーダー順変更
     */
    public
    function posts_orderby($orderby)
    {
        if (!empty(get_query_var('sort'))) {
            switch (get_query_var('sort')) {
                //賃料が高い順
                case 'higher':
                    $orderby = $this->search_table . '.price DESC';
                    break;
                //賃料が安い順
                case 'lower':
                    $orderby = $this->search_table . '.price ASC';
                    break;
                //面積が広い順
                case 'floor':
                    $orderby = $this->search_table . '.floor_area DESC';
                    break;
            }
        }
        return $orderby;
    }

    /**
     * 条件絞り込み
     */
    public
    function posts_where($where)
    {
        //booleanで判定できる項目
        foreach ($this->data_structure as $c => $t) {
            if ($t === 'bool' && !empty(get_query_var($c)) && get_query_var($c) === '1') {
                $where .= " AND " . $this->search_table . "." . $c . " = '1'";
            }
        }

        //数値がいずれかとマッチ
        foreach (array('type', 'floor', 'room_type', 'status') as $c) {
            if (!empty(get_query_var($c))) {
                $in = array();
                foreach (get_query_var($c) as $k => $v) {
                    if (is_numeric($k) && is_numeric($v) && $v === '1') $in[] = "'" . $k . "'";
                }
                if (!empty($in)) $where .= " AND " . $this->search_table . "." . $c . " IN (" . implode(',', $in) . ")";
            }
        }

        //面積・坪数
        foreach (array('floor_space') as $c) {
            if (!empty(get_query_var('min_' . $c)) && is_numeric(get_query_var('min_' . $c)) && get_query_var('min_' . $c) > 0) {
                $where .= " AND " . $this->search_table . "." . $c . " >= " . get_query_var('min_' . $c);
            }
            if (!empty(get_query_var('max_' . $c)) && is_numeric(get_query_var('max_' . $c)) && get_query_var('max_' . $c) > 0) {
                $where .= " AND " . $this->search_table . "." . $c . " <= " . get_query_var('max_' . $c);
            }
        }

        //価格 共益費込みにチェックがある場合は共益費込みで
        $price_common = (!empty(get_query_var('include_common')) && get_query_var('include_common') === '1') ? ' + ' . $this->search_table . '.price_common' : '';
        if (!empty(get_query_var('min_price')) && is_numeric(get_query_var('min_price')) && get_query_var('min_price') > 0) {
            $where .= " AND (" . $this->search_table . ".price " . $price_common . ") >= " . get_query_var('min_price');
        }
        if (!empty(get_query_var('max_price')) && is_numeric(get_query_var('max_price')) && get_query_var('max_price') > 0) {
            $where .= " AND (" . $this->search_table . ".price " . $price_common . ") <= " . get_query_var('max_price');
        }

        return $where;
    }
}
```

##解説
上記のコードは、機能の内、各不動産を登録する際の挙動と、一覧ページでのクエリの書き換え部分のみ記載しています。

1. 記事登録時にACFで登録した値をget_fieldsで取得し、検索用テーブルに登録します。<br>その際、各カラムの構造に合わせて値をフォーマットします。
2. 「共益費込み」や、面積での検索用に’min_’と’max_’をプリフィックスにつけたキーも登録しておきます。
3. 一覧ページ・検索結果ページのメインクエリをカスマイズします。meta_queryを使わず、検索用テーブルをJOINさせることで処理を高速化させます。

#あとがき
さて、魔改造三部作、いかがだったでしょうか？
実践する機会は限りなく0に近いものばかりですが、WordPressカスタマイズの考え方の参考にはなったら嬉しいです。
WordPress5.0のリリースも間近。2019年も楽しい魔改造が待っていることを願って・・・。

#おまけ（宣伝）
この記事が公開される頃、私のバンド「GRAND FAMILY ORCHESTRA」はちょうど東名阪ツーマンツアー直前のはず。
下記日程でご都合つくようでしたらぜひ遊びに来て下さいね。

12月9日（日）名古屋栄CLUB ROCK’N’ROLL
12月14日（金）大阪心斎橋Pangea
12月22日（土）渋谷O-Crest

それではまた。