クエリの魔術から身を守れ:`posts_request` フックによるSQL改変の極意とリスク管理
テックリードの私だ。コードレビューで「`WP_Query` が重いので、`posts_request` で直接SQLを書き換えました」というプルリクエストを見るたびに、私の眉間には深いシワが刻まれる。
気持ちは痛いほど分かる。複雑な条件分岐、カスタムテーブルとの結合、パフォーマンスチューニングの限界……。`WP_Query` の生成するSQLを直接ハックしたくなる衝動に駆られる瞬間は、大規模なWordPress開発において少なくない。
だが、一歩間違えれば、そのコードは致命的なSQLインジェクションの温床となり、オプティマイザを混乱させてデータベースを崩壊させる。
今回は、WordPressコアのクエリライフサイクルにおける `posts_request` の挙動を完全に解体し、「セキュリティを担保しながら、極限までパフォーマンスを引き出すSQLの動的書き換えパターン」を実務レベルのコードと共に伝授する。
—
1. なぜ `posts_request` なのか? コアの実行順序を理解する
まず、`WP_Query` がどのようにSQLを組み立て、発行しているかを思い出してほしい。
[WP_Query インスタンス化]
↓
[クエリ変数のパース (parse_query)]
↓
[SQLの生成 (get_posts)] ★ここが「posts_request」の瞬間
↓
[データベースへの問い合わせ ($wpdb->get_results)]
↓
[ポストデータのキャッシュ & オブジェクト化]
`posts_request` フィルターは、WordPressがデータベースへクエリを投げる直前に実行される。ここでは、生成された完全なSQL文字列が生の状態で渡される。
add_filter( ‘posts_request’, ‘my_custom_posts_request’, 10, 2 );
function my_custom_posts_request( $sql, $query ) {
// $sql は生成された SELECT SQL 文字列
// $query は WP_Query のインスタンス
return $sql;
}
このフックの最大の魅力は、`WP_Query` が提供する貧弱なパラメータ(`meta_query` や `tax_query`)の呪縛から解放され、任意の `JOIN` や複雑な `WHERE` 句をねじ込める点にある。しかし、それは同時に「WordPressのクエリビルダーの安全網をすべてかなぐり捨てる」ことを意味する。
—
2. やってはいけないアンチパターン:なぜ生SQLの直叩きは地獄を生むのか
ジュニアやミドルクラスの開発者がやりがちな最悪のパターンを見てみよう。
// 【絶対にしてはいけないアンチパターン】
add_filter( ‘posts_request’, function( $sql, $query ) {
if ( ! $query->is_main_query() && $query->get( ‘my_custom_filter’ ) ) {
$keyword = $_GET[‘s’]; // ⚠️ ユーザー入力を直接参照している
// ⚠️ プリペアされていない危険な文字列結合
$sql = str_replace(
“WHERE 1=1”,
“WHERE 1=1 AND post_title LIKE ‘%” . $keyword . “%'”,
$sql
);
}
return $sql;
}, 10, 2 );
このコードの問題点を挙げてみよう。
1. SQLインジェクションの完全な放置: `$_GET[‘s’]` がサニタイズやプリペアなしでSQLに組み込まれており、データベースが簡単に乗っ取られる。
2. 文字列置換の脆弱性: `str_replace(“WHERE 1=1”, …)` は非常に脆い。WordPressのコアアップデートや他のプラグインの介入によって `WHERE 1=1` の構造が変わった瞬間、SQL構文エラーを引き起こすか、意図しない条件が適用される。
3. コンテキストの無視: どの `WP_Query` が実行されているかの判定が甘く、意図しない画面(管理画面やREST APIなど)までSQLが書き換わる。
プロのエンジニアであれば、このようなコードを本番環境にマージした時点で即座にリジェクトすべきだ。
—
3. 【プロダクション品質】安全かつ高速な `posts_request` 実装パターン
では、どのように実装すべきか。
今回は、「特定のカスタムメタ値と外部カスタムテーブルを効率的に結合し、かつ安全にソート条件を追加する」高度な要件を想定したプロダクションコードを示す。
このコードでは以下の要件を満たしている。
- 完全なプレースホルダーの使用 (`$wpdb->prepare`)
- 対象クエリの厳密なスコープ管理 (`is_main_query` やカスタムフラグ)
- 正規表現を用いた堅牢なSQLパーツの置換(または結合)
- インデックスを考慮したクエリ設計
/
declare( strict_types=1 );
namespace Enterprise\QueryOptimization;
class SecureQueryRewriter {
public static function init(): void {
add_action( ‘pre_get_posts’, [ self::class, ‘set_custom_query_flag’ ] );
add_filter( ‘posts_request’, [ self::class, ‘optimize_and_rewrite_sql’ ], 10, 2 );
}
/
- 1. 対象となるWP_Queryにカスタムフラグを安全に付与する
- pre_get_postsの段階でクエリを識別し、無駄なフック発火を防ぐ
/
public static function set_custom_query_flag( \WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// 例:特定のアーカイブページ、またはカスタムパラメータが存在する場合のみ有効化
if ( ‘product’ === $query->get( ‘post_type’ ) && $query->get( ‘enable_custom_geo_sort’ ) ) {
$query->set( ‘enterprise_geo_optimized’, true );
// ユーザー入力を安全に取得・キャストしておく
$user_lat = filter_input( INPUT_GET, ‘lat’, FILTER_VALIDATE_FLOAT );
$user_lng = filter_input( INPUT_GET, ‘lng’, FILTER_VALIDATE_FLOAT );
if ( false !== $user_lat && false !== $user_lng ) {
$query->set( ‘enterprise_lat’, $user_lat );
$query->set( ‘enterprise_lng’, $user_lng );
}
}
}
/
- 2. posts_requestでの安全なSQL書き換え
/
public static function optimize_and_rewrite_sql( string $sql, \WP_Query $query ): string {
// 自作のフラグがない場合は、即座に元のSQLを返す(オーバーヘッドを最小化)
if ( ! $query->get( ‘enterprise_geo_optimized’ ) ) {
return $sql;
}
global $wpdb;
$lat = $query->get( ‘enterprise_lat’ );
$lng = $query->get( ‘enterprise_lng’ );
if ( null === $lat || null === $lng ) {
return $sql;
}
// 【極めて重要】外部変数は必ず $wpdb->prepare を通すこと。
// ハーバーサイン公式を使った緯度経度による距離計算(例)
// 実際のプロダクションでは、カスタムテーブル `{$wpdb->prefix}store_locations` が存在すると仮定
$geo_select = $wpdb->prepare(
“, ( 6371 acos( cos( radians(%f ) ) cos( radians( geo.latitude ) ) cos( radians( geo.longitude ) – radians(%f ) ) + sin( radians(%f ) ) sin( radians( geo.latitude ) ) ) ) AS distance”,
$lat,
$lng,
$lat
);
$geo_join = ” INNER JOIN {$wpdb->prefix}store_locations AS geo ON {$wpdb->posts}.ID = geo.post_id “;
// SQLの構文構造を解析し、安全に文字列を挿入する
// SELECT句の直後にカスタム演算を追加
$sql = preg_replace( ‘/^SELECT\s+/i’, ‘SELECT SQL_CALC_FOUND_ROWS ‘ . $wpdb->posts . ‘. ‘ . $geo_select, $sql, 1 );
// FROM句の直後にJOINを挿入
// str_replaceの代わりに対象のキーワードを1箇所だけ置換する安全なアプローチ
$from_pos = strpos( $sql, “FROM {$wpdb->posts}” );
if ( false !== $from_pos ) {
$sql = substr_replace( $sql, $geo_join, $from_pos, 0 );
}
// ORDER BY を距離順に強制上書き(必要に応じて)
// 既存のORDER BY句を安全に置換、または追加
if ( false !== strpos( $sql, ‘ORDER BY’ ) ) {
$sql = preg_replace( ‘/ORDER BY\s+.?($|\sLIMIT)/i’, ‘ORDER BY distance ASC $1’, $sql );
} else {
// ORDER BYがない場合は末尾(LIMITの前など)に追加する高度なパース処理をここに記述
}
return $sql;
}
}
// 初期化
SecureQueryRewriter::init();
—
4. エンジニアが知るべきパフォーマンス上の罠とオプティマイザ対策
このコードを実装して「よし、動いた」で終わらせては、テックライツの称号返上だ。データベースの挙動を深く理解し、以下のポイントを必ず検証してほしい。
A. `SQL_CALC_FOUND_ROWS` とペネトレーション
上記のコードでは説明のために `SQL_CALC_FOUND_ROWS` を入れているが、現代の大規模MySQL / MariaDB環境において、この構文はパフォーマンスキラーとして知られている。
もし数百万件のレコードを持つテーブルでこれをやると、MySQLはLIMIT句を無視して全件スキャンに近いコストを支払うことになる。
ペネトレーションテストや負荷試験を行う際は、必ず `EXPLAIN` を実行し、`type` カラムが `ALL` になっていないか(インデックスが効いているか)を確認すること。可能であれば、総件数の取得には別の軽量なTransientキャッシュ戦略を組み合わせるべきだ。
B. オブジェクトキャッシュとの整合性
`posts_request` でSQLを根本から書き換えた場合、WP_Queryのキャッシュキー(`generate_cache_key` や内部キャッシュ)が正しく機能しなくなるリスクがある。
カスタムパラメータ(今回の場合は `lat` や `lng`)がキャッシュキーに正しく反映されているかをチェックし、意図しないユーザーの間でキャッシュが共有(キャッシュ汚染)されないよう、`posts_clauses` フィルターの利用も並行して検討せよ。
(※実は、単なるJOINやWHEREの追加であれば、`posts_request` よりも粒度が細かい `posts_join` や `posts_where` フックを使う方が安全な場合が多い)。
—
5. まとめ
`posts_request` フックは、WordPressのデータベースレイヤーにおける「最後の切り札」だ。正しく使えば、どんなに複雑なビジネスロジックや地理空間検索をもWordPressに統合できる。
だが、力には常に責任が伴う。
- ユーザー入力は100%疑い、必ず `$wpdb->prepare` で封じ込める。
- フックのスコープを極限まで絞り込み、グローバルな副作用を防ぐ。
- 常に `EXPLAIN` を叩き、インデックスが機能していることを証明してから本番へデプロイする。
この規律を守れる者だけが、WordPressを真に掌握し、極限のパフォーマンスと堅牢性を両立したシステムを構築できる。次のコードレビューで、君の美しいプルリクエストが見られることを楽しみにしている。