WordPress検索でカスタムフィールドも対象にする方法(functions.php)

PCで検索

WordPressをある程度使い込んでいる方ならご存じだと思いますが、標準のサイト内検索はカスタムフィールドの値を検索対象に含めません

検索されるのは投稿タイトルと本文(と抜粋)だけで、カスタムフィールド(postmetaテーブルに保存されるデータ)は素通りです。そのため、例えば次のようなケースでは「あるはずのデータが検索でヒットしない」という状態になります。

  • 店舗情報サイトで住所や電話番号をカスタムフィールドに保存している
  • 商品データの型番やSKUをカスタムフィールドで管理している
  • ACF(Advanced Custom Fields)で追加した情報を検索対象にしたい

特にデータ量が多いサイトほど、この仕様はかなり不便です。実際、私が運用しているサイトでも記事数が10万件近くあり、検索精度を上げるためにカスタムフィールド検索は必須でした。

そこで、

  • プラグインで解決する方法
  • functions.phpで直接対応する方法

この2つを実際に試しました。最終的に一番安定して運用できたのはコードで対応する方法だったので、その結論も含めて紹介します。

プラグインで済ませたかったが…(Search Everything)

まず試したのが、昔から定番として名前の挙がる検索拡張プラグインSearch Everythingです。管理画面から有効化するだけでカスタムフィールドを検索対象に追加できる、という手軽さが魅力でした。

ただ、正直に言うと今から新規に入れるのはおすすめしません。このプラグインはもう長らく更新が止まっており、プラグインページにも「最新3回のメジャーリリースに対してテストされていません」という警告が出ています。レビューでも「検索機能が壊れた」「現行のWordPressでは動かない」という声が並んでいる状態です。

さらに私の環境では、動いていた頃でも検索処理がかなり重くなりました。記事数が多いとクエリが膨らみやすく、10万件規模+大量のカスタムフィールドという条件では、体感でわかるくらい表示が遅くなって実用レベルには届きませんでした。

「更新が止まったプラグインに検索という基幹機能を預ける」こと自体がリスクなので、ここは早めに見切りをつけました。

特定フィールドを指定できるプラグインも試したが更新停止に

次に試したのが、検索対象にするカスタムフィールドを個別に指定できるタイプのプラグインです。必要なフィールドだけを対象にできるので、

  • 検索対象のカスタムフィールドを指定できる
  • 不要なメタデータを除外できる
  • その分だけ検索処理が軽い

と、導入当初は狙いどおりに動いてくれました。

ところが、あるアップデートを境に突然動かなくなりました。原因は特定しきれませんでしたが、PHPやWordPress本体のバージョン変更、そしてプラグイン側の更新停止が重なったあたりが怪しいと見ています。結局このプラグインも公式ディレクトリから姿を消してしまい、断念しました。

プラグインは手軽ですが、更新停止や互換性切れのリスクからは逃げられません。ここで「自分でコードを持っておいたほうが結局ラクだ」と腹をくくりました。

functions.phpでカスタムフィールドを検索対象にする

最終的に採用したのがfunctions.phpに検索処理を追加する方法です。

WordPressの検索は、内部的にSQLのJOIN・WHERE・DISTINCTといった句を組み立てて実行しています。ここには posts_join / posts_where / posts_distinct というフィルターフックが用意されていて、生成されるSQLに割り込めます。この3つを使ってpostmetaテーブルのmeta_valueも検索対象に加えるのが、いちばん素直で安定した実装です。

以下を使っているテーマ(できれば子テーマ)のfunctions.phpに貼り付けてください。

// 1. postmetaテーブルを検索クエリにJOINする
function wn_cf_search_join( $join, $wp_query ) {
    global $wpdb;
    if ( ! is_admin() && $wp_query->is_search() && $wp_query->is_main_query() ) {
        $join .= " LEFT JOIN {$wpdb->postmeta} ON {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id ";
    }
    return $join;
}
add_filter( 'posts_join', 'wn_cf_search_join', 10, 2 );

// 2. WHERE句にmeta_valueへのLIKE検索を追加する
function wn_cf_search_where( $where, $wp_query ) {
    global $wpdb;
    if ( ! is_admin() && $wp_query->is_search() && $wp_query->is_main_query() ) {
        // 「post_title LIKE ...」の条件に「OR meta_value LIKE ...」を差し込む
        $where = preg_replace(
            "/\(\s*{$wpdb->posts}.post_title\s+LIKE\s*('[^']+')\s*\)/",
            "({$wpdb->posts}.post_title LIKE $1) OR ({$wpdb->postmeta}.meta_value LIKE $1)",
            $where
        );
    }
    return $where;
}
add_filter( 'posts_where', 'wn_cf_search_where', 10, 2 );

// 3. JOINで増えた重複行をDISTINCTでまとめる
function wn_cf_search_distinct( $distinct, $wp_query ) {
    if ( ! is_admin() && $wp_query->is_search() && $wp_query->is_main_query() ) {
        return 'DISTINCT';
    }
    return $distinct;
}
add_filter( 'posts_distinct', 'wn_cf_search_distinct', 10, 2 );

やっていることは3ステップです。

  • posts_join:wp_postmetaテーブルをLEFT JOINして、投稿とメタ値を結び付ける
  • posts_where:既存の「タイトルをLIKE検索する」条件に「OR meta_valueもLIKE検索する」を追加する
  • posts_distinct:1投稿に複数メタがあるとJOINで行が重複するので、DISTINCTで1件にまとめる

ポイントは、フロント側の検索本体だけに効かせることです。is_admin() を除外し、is_main_query() も確認しているのは、管理画面の検索やウィジェット・関連記事などのサブクエリまで巻き込んで重くしないためです。プラグインのように余計な処理が挟まらないぶん、大規模サイトでも比較的安定して動いてくれます。

LINK posts_join(WordPress Developer Resources)

入力値を安全に扱う(プレースホルダー検索)

上のコードはWordPressが組み立て済みのWHERE句を加工しているので、検索語そのものはコア側でエスケープされています。ただ、自分で検索語を組み立ててSQLに埋め込むときは、必ずエスケープしてください。ここを素の文字列連結で書くとSQLインジェクションの入り口になります。

現行のWordPressでは、LIKE検索の値は $wpdb->esc_like() でワイルドカード(%と_)をエスケープし、$wpdb->prepare() のプレースホルダー %s 経由で渡すのが定石です。posts_search フィルターを使って書くなら、こんな形になります。

function wn_search_meta( $search, $wp_query ) {
    global $wpdb;
    if ( is_admin() || ! $wp_query->is_search() || ! $wp_query->is_main_query() ) {
        return $search;
    }
    $term = $wp_query->get( 's' );
    if ( '' === $term ) {
        return $search;
    }
    // %と_をエスケープし、前後にワイルドカードを付けて値として渡す
    $like = '%' . $wpdb->esc_like( $term ) . '%';
    $search = $wpdb->prepare(
        " AND ( {$wpdb->posts}.post_title LIKE %s"
        . " OR {$wpdb->posts}.post_content LIKE %s"
        . " OR EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm"
        . " WHERE pm.post_id = {$wpdb->posts}.ID AND pm.meta_value LIKE %s ) ) ",
        $like, $like, $like
    );
    return $search;
}
add_filter( 'posts_search', 'wn_search_meta', 10, 2 );

こちらは EXISTS でサブクエリにしているので、JOINで行が重複せずDISTINCTが要りません。「タイトル・本文・全メタ値のどれかに一致すればヒット」という素直な挙動になります。旧来よく見かける esc_sql() だけの実装ではワイルドカードのエスケープが漏れがちなので、LIKEの値は esc_like() → prepare() の順で通すと覚えておくと安全です。

LINK wpdb::esc_like()(WordPress Developer Resources)

特定のカスタムフィールドだけ検索対象にする

すべてのメタ値を検索対象にすると、ノイズが増えることがあります。例えば次のような内部用のデータまで引っかかると、検索結果が意図しないものになりがちです。

  • 郵便番号(zip)
  • 内部管理用のID(number)
  • アクセス解析用の値(access)

この場合は、対象にするmeta_keyをホワイトリストで絞り込むのが安全です。meta_key NOT LIKE ... で除外していく書き方もできますが、フィールドが増えるほど漏れやすいので、私は「検索したいキーだけを明示的に許可する」方向をおすすめします。先ほどのWHEREフィルターを次のように差し替えます。

function wn_cf_search_where( $where, $wp_query ) {
    global $wpdb;
    if ( ! is_admin() && $wp_query->is_search() && $wp_query->is_main_query() ) {
        // 検索対象にしたいカスタムフィールドのキーだけを列挙する
        $allowed_keys = array( 'address', 'tel', 'product_code' );
        $in = "'" . implode( "','", array_map( 'esc_sql', $allowed_keys ) ) . "'";

        $where = preg_replace(
            "/\(\s*{$wpdb->posts}.post_title\s+LIKE\s*('[^']+')\s*\)/",
            "({$wpdb->posts}.post_title LIKE $1)"
            . " OR ({$wpdb->postmeta}.meta_key IN ({$in})"
            . " AND {$wpdb->postmeta}.meta_value LIKE $1)",
            $where
        );
    }
    return $where;
}

$allowed_keys に検索対象のキー(例では住所・電話番号・型番)だけを並べておけば、それ以外のメタ値はヒットしません。キー名は esc_sql() でエスケープしてから IN 句に入れています。除外したいキーが決まっているなら、逆に meta_key NOT IN (...) に書き換えても同じ考え方で使えます。

結局いちばん安定するのはコード対応

WordPressの検索はシンプルなぶん、フィルターフックで自由に拡張できる懐の深い仕組みになっています。今回の比較を整理すると、こうなります。

  • プラグインは手軽だが、大規模サイトでは重くなりやすい
  • 更新停止・互換性切れで突然動かなくなるリスクがある

一方、functions.phpで対応する方法には次のメリットがあります。

  • JOIN・WHERE・DISTINCTの処理内容を自分で完全にコントロールできる
  • 余計な処理が入らず、大規模サイトでも比較的安定する
  • 対象フィールドの絞り込みも自由自在

カスタムフィールドの活用が前提のCMSなのに、標準検索がそれを拾ってくれないのは正直もどかしい仕様です。ですが、フックを3つ足すだけで実用レベルには持っていけます。導入するときは、(1) is_admin と is_main_query で効かせる範囲を絞る、(2) LIKEの値は esc_like と prepare で通す、(3) 対象キーは必要なぶんだけ許可する――この3点だけ押さえておけば、重さも安全性も心配なく運用できるはずです。検索の精度はサイトの使い勝手に直結するので、カスタムフィールドを多用しているなら一度見直してみてください。