WP_Queryの`posts_where`ハック:`REGEXP`置換が引き起こすデータベース破滅と、その生存戦略
テックリードの私だ。コードレビューをしていると、いまだにこんなコードを見かける。
「カスタムフィールドを複数キーワードで柔軟に検索させたいから、`posts_where`フックで`LIKE`を`REGEXP`に書き換えました!」
……止むを得ない事情がない限り、私のレビューは即座に「Request Changes(変更要求)」だ。
なぜこれがデータベースのサイレントキラーになり得るのか。そして、どうしても複雑なパターンマッチングが必要な現場において、いかにしてインデックスを殺さずに生き残るか。WordPressコアの内部挙動を踏まえながら、実務で通用する堅牢な設計と実装解を授けよう。
—
1. 内部解剖:なぜ `posts_where` での `REGEXP` はスロークエリの温床となるのか
まず、WordPressのクエリ生成パイプラインを思い出してほしい。`WP_Query` は、私たちが指定した引数を元に SQL を組み立て、最終的に `$wpdb->query()` へと流し込む。
その過程で用意されているのが、SQLの `WHERE` 句を直接文字列置換・追加するための `posts_where` フィルタだ。
add_filter( ‘posts_where’, function( $where, \WP_Query $query ) {
// 危険な置換の例
$where = str_replace( “LIKE ‘”, “REGEXP ‘”, $where );
return $where;
}, 10, 2 );
一見、スマートに高度な検索ができるように見える。だが、ここに致命的なパフォーマンスリスクが潜んでいる。
フルテーブルスキャン(全件走査)の強制
MySQL(InnoDB)において、`LIKE ‘%keyword%’`(前方一致以外のワイルドカード)と同様に、`REGEXP` や `RLIKE` 演算子はB-Treeインデックスを一切活用できない(Non-SARGableな条件)。
クエリが実行された瞬間、MySQLはインデックスを参照するすべを失い、対象テーブル(`wp_posts` や `wp_postmeta`)の全レコードをメモリ上に読み込み、1行ずつ正規表現エンジンで評価していく(Full Table Scan)。
データ量が数万件程度であればサーバのパワーでゴリ押しできるかもしれないが、数百万件規模のプロダクション環境では、このクエリが数発同時に入っただけで一瞬でスレッドプールが枯渇し、データベースが沈黙する。
—
2. それでも「REGEXP」が必要なユースケースとは?
では、`REGEXP` は一切使ってはならない悪なのか? 答えはノーだ。設計のトレードオフを理解していれば、限定的なユースケースにおいては強力な武器になる。
- 完全かつ柔軟な単語境界マッチング: `LIKE` だと部分一致で意図しないレコードまで拾ってしまうケース(例: 「cat」で検索して「category」がヒットするのを防ぎ、単語単位で厳密に一致させたい場合)。
- 複雑なOR条件の簡略化: 「AかつB」ではなく、「(X1|X2|X3)のいずれかを含み、かつYのパターンに合致する」といった、SQLの `LIKE` を乱立させるとスパゲッティ化する条件を1つの正規表現で美しく表現したい場合。
ただし、これをメインの投稿テーブルや肥大化したメタテーブルに対して無条件で実行してはならない。
—
3. 【プロダクションコード】安全性を担保したハイブリッド検索の実装
実務において、パフォーマンスと検索要件のジレンマを解決するための設計パターンを提示しよう。
ここでのアプローチは明確だ:
1. 対象を絞り込む: `posts_where` をグローバルに汚染せず、特定のメインクエリ(例: 固有のカスタムキーを持つリクエスト)にのみ限定する。
2. プレフィックスの最適化: 正規表現の先頭がワイルドカード(`.`)から始まらないように制約を設け、可能な限りのコスト削減を図る。
3. オブジェクトキャッシュの活用: クエリ結果をトランジェント等でキャッシュし、DBヒット頻度そのものを激減させる。
以下に、バグの起きない堅牢な実装コードを示す。
/
namespace Enterprise\Search;
class Regexp_Query_Optimizer {
/
- フックの登録
/
public static function init(): void {
add_action( ‘pre_get_posts’, [ self::class, ‘target_specific_query’ ] );
}
/
- 特定のカスタムクエリフラグが立った場合のみ、where句のフックを有効化する
- @param \WP_Query $query
/
public static function target_specific_query( \WP_Query $query ): void {
// 管理画面やメインクエリ以外、またはカスタムフラグがない場合は早期リターン
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->get( ‘enable_secure_regexp_search’ ) === true ) {
// クロージャ内でスコープを保ちつつ、フィルタを動的アタッチ
add_filter( ‘posts_where’, [ self::class, ‘apply_safe_regexp_where’ ], 10, 2 );
// クエリ終了後にフィルタを自動解除して他のクエリへの影響(副作用)を防ぐ
add_action( ‘posts_selection’, [ self::class, ‘remove_safe_regexp_where’ ], 10, 1 );
}
}
/
- 安全性を考慮したREGEXPへの置換処理
- @param string $where
- @param \WP_Query $query
- @return string
/
public static function apply_safe_regexp_where( string $where, \WP_Query $query ): string {
global $wpdb;
$search_term = $query->get( ‘secure_regexp_term’ );
if ( empty( $search_term ) ) {
return $where;
}
// セキュリティ対策: 入力値をSQLインジェクションから保護しつつ、正規表現として安全な形にサニタイズ
// ※ここでは半角英数字と一部の安全な記号のみを許可する例
$sanitized_term = preg_replace( ‘/[^a-zA-Z0-9_\-\|]/’, ”, $search_term );
if ( empty( $sanitized_term ) ) {
return $where;
}
// 単語境界を意識した安全なREGEXPパターンを作成 (例: \bkeyword\b)
// ※ ワイルドカードから始まるパターンを強制排除し、オプティマイザへの負担を軽減
$regexp_pattern = ‘[[:<:]]' . esc_sql( $sanitized_term ) . '[[:>:]]’;
// LIKE句をピンポイントで置換(貪欲なstr_replaceは厳禁)
// wp_posts.post_title に対する検索をターゲットにする場合
$target_sql_like = $wpdb->prepare( “{$wpdb->posts}.post_title LIKE %s”, ‘%’ . $wpdb->esc_like( $search_term ) . ‘%’ );
$target_sql_regexp = “{$wpdb->posts}.post_title RLIKE ‘{$regexp_pattern}'”;
// 対象のLIKE句が存在する場合のみ安全に置換
if ( strpos( $where, $target_sql_like ) !== false ) {
$where = str_replace( $target_sql_like, $target_sql_regexp, $where );
}
return $where;
}
/
- 副作用を防ぐため、クエリ実行直後にフィルタをデタッチ
- @param \WP_Query $query
/
public static function remove_safe_regexp_where( \WP_Query $query ): void {
remove_filter( ‘posts_where’, [ self::class, ‘apply_safe_regexp_where’ ], 10 );
}
}
// 初期化
Regexp_Query_Optimizer::init();
この設計の優れている点(コードレビューの視点)
1. スコープの完全な制御 (`pre_get_posts` との連動)
グローバルに `posts_where` を書き換えると、意図しないウィジェットや他のプラグインのクエリまで破壊する。この実装では、明示的に `$query->set( ‘enable_secure_regexp_search’, true )` を指定したクエリでのみ動作する。
2. 副作用の確実な排除 (`posts_selection`)
クエリビルドの最終段階でフックを即座に `remove` している。これにより、同一リクエスト内で後続する別の `WP_Query` への汚染を防いでいる。
3. 安全なエスケープとバリデーション
ユーザー入力をそのまま `REGEXP` に渡すと、ReDoS(正規表現サービス拒否)攻撃の温床になる。許可された文字種以外を厳しく削ぎ落とし、`[[:<:]]` / `[[:>:]]`(MySQLの単語境界)を用いることで、意図しない過大マッチを防いでいる。
—
4. テックリードからの提言:本当にREGEXPが必要か?
最後にアーキテクチャの観点からアドバイスを送ろう。
もしあなたが「高度な検索機能」や「柔軟なパターンマッチング」を実装するために `REGEXP` の導入を検討しているなら、それはデータベース層の仕事ではなく、検索エンジンの領域だ。
- Elasticsearch / OpenSearch や、マネージドであれば Algolia などの外部検索インデックスサービスを連携させるべきだ。
- あるいは、WordPress標準の機能にこだわるのであれば、検索対象の文字列をあらかじめ正規化(トカナイズ)して別カラム(または専用テーブル)に保持し、通常の `LIKE` や `IN` 句でヒットさせられるデータ構造へモデリングし直すのが王道である。
ORMの糖衣 syntax やフックの便利さに逃げ、データベースの特性を無視したコードを書くエンジニアを、私はシニアとは認めない。システムのスケールを見据え、どこに負荷のボトルネックが生まれるかを常に脳内でプロファイリングしながらコードを書け。
健闘を祈る。