PHPのpreg_quoteで検索語を正規表現に安全に埋め込む。区切り文字の渡し忘れと置換文字列の落とし穴

preg_quoteは、ユーザー入力や変数の文字列を正規表現の中に「ただの文字列」として埋め込むために、正規表現の特殊文字をエスケープする関数です。第2引数に区切り文字を渡し忘れると、静かにパターンが壊れます。

検索語をそのまま正規表現に連結する書き方、実務ではけっこう見かけます。私もやったことがあります。テストの入力が普通の英単語のうちは動いてしまうので、気づくのがだいぶ後になるんですよね。

検索語をそのまま連結すると何が起きるのか

たとえばハイライト機能で、入力された検索語を '/' . $kw . '/u' のようにパターンへ連結したとします。. や ( や [ が入った瞬間に、意図しないパターンに化けます。

<?php
$text = '(株)山田商事 と arr[0] と 1+1=2';

// 「(株)」はグループ扱いになり、括弧そのものではなく「株」にマッチする
preg_match('/' . '(株)' . '/u', $text, $m);
echo $m[0]; // 株

// 「arr[」は文字クラスが閉じておらずコンパイルエラー(E_WARNING)、戻り値はfalse
$r = preg_match('/' . 'arr[' . '/u', $text);
var_dump($r); // bool(false)

// 「1+1」は「1が1回以上のあとに1」と解釈されるので、元の文字列「1+1」にはマッチしない
var_dump(preg_match('/' . '1+1' . '/u', $text)); // int(0)

怖いのは、エラーにならずに「違うものにマッチする」ケースです。「(株)」の例は警告も出ません。ログにも残らないまま、結果だけが少しずれます。

preg_quoteの基本の使い方はどう書く?

シグネチャは preg_quote(string $str, ?string $delimiter = null): string です。第1引数が埋め込みたい文字列、第2引数が「パターンの区切り文字として使っている1文字」です。

<?php
$text = '(株)山田商事 と arr[0] と 1+1=2';

foreach (['(株)', 'arr[', '1+1'] as $kw) {
    $pattern = '/' . preg_quote($kw, '/') . '/u';
    preg_match($pattern, $text, $m);
    echo $m[0], "\n";
}
// (株)
// arr[
// 1+1

これで、検索語に何が入っていても「その文字列そのもの」にマッチします。パターンを組み立てる側が、入力の中身を気にしなくてよくなるのが一番の利点だと思います。

なぜ第2引数の区切り文字が必要なのか

preg_quoteは、デフォルトでは区切り文字をエスケープしません。/ は正規表現の特殊文字ではないからです。つまり、区切り文字を渡さないと、パターンを閉じる文字が入力に混ざったときに壊れます。

<?php
echo preg_quote('a/b~c', '~'), "\n"; // a/b\~c  (~が区切り文字なので~だけ追加で退避される)
echo preg_quote('a/b~c'), "\n";      // a/b~c   (どちらも退避されない)

// 区切りを ~ にしているのに第2引数を渡さないと、パターンが途中で閉じてしまう
var_dump(@preg_match('~' . preg_quote('a/b~c') . '~', 'a/b~c')); // bool(false)

区切り文字を / 以外にしたときほど忘れやすいところです。私は、区切り文字を変数や定数にまとめて、パターンの組み立てと preg_quote の両方で同じものを使うようにしています。

なお、# はPHP 7.3以降、デフォルトでエスケープされます(xフラグ付きで使うときのためです)。古いPHPのコードを読むときは、そこだけ挙動が違う可能性があります。スペースはエスケープされないので、xフラグ(コメントと空白を無視するモード)と組み合わせるときは、空白が無視される点に注意してください。

複数のキーワードを OR で連結するときの落とし穴

キーワードの配列を | でつなぐパターンは、ハイライトやNGワード検出でよく書きます。ここでは、それぞれに preg_quote をかけるのと、並び順の2点を押さえておくと安心です。

<?php
$kws = ['C', 'C++', '.NET'];

// 長い語を先に並べる。先頭から順に試されるので、短い 'C' が先だと 'C++' の途中で確定してしまう
usort($kws, fn(string $a, string $b): int => strlen($b) <=> strlen($a));

$pattern = '/(?:' . implode('|', array_map(
    fn(string $k): string => preg_quote($k, '/'),
    $kws
)) . ')/u';

echo $pattern, "\n"; // /(?:\.NET|C\+\+|C)/u

preg_match($pattern, 'C++', $m);
echo $m[0]; // C++

パターンは先頭の候補から順に試され、最初に成功した候補で確定します。['C', 'C++'] の順だと C だけで確定するので、長い語を先に並べ替えてから連結しています。strlen はバイト数ですが、並び順を決めるだけなので日本語が混ざっていても、ここでは実害はありません。

置換文字列には preg_quote が効かない

もうひとつの落とし穴が、preg_replace の第2引数(置換文字列)です。ここでは $1 や \1 が後方参照として解釈されます。preg_quoteは「パターン側」用の関数なので、置換文字列には使えません。

<?php
// 置換文字列の「$5」が5番目のグループへの参照と解釈され、存在しないので空になる
echo preg_replace('/foo/', 'cost $5', 'foo'), "|\n"; // cost |

// コールバックなら戻り値がそのまま使われる
echo preg_replace_callback('/foo/', fn(array $m): string => 'cost $5', 'foo'), "|\n"; // cost $5|

金額の「$100」のような文字列が置換側に入りうるなら、preg_replace_callback でコールバックの戻り値として返すのが確実です。ハイライト用の関数はこうなります。

<?php
function highlight(string $text, string $kw): string
{
    $pattern = '/' . preg_quote($kw, '/') . '/u';
    return preg_replace_callback(
        $pattern,
        fn(array $m): string => '【' . $m[0] . '】',
        $text
    );
}

echo highlight('価格は$100です。$100は税込。', '$100');
// 価格は【$100】です。【$100】は税込。

パターン側は preg_quote、置換側はコールバック、と役割を分けて覚えておくと、あとで迷いません。

そもそも正規表現が要らない場面もある

単に「含まれているか」を調べたいだけなら、str_contains や mb_strpos のほうが速くて読みやすいです。preg_quoteが活きるのは、大文字小文字の無視(iフラグ)や単語境界、他のパターンとの組み合わせなど、正規表現の機能を使いたいときだと思います。

まとめ

変数を正規表現に埋め込むときは、preg_quote を通して、第2引数にパターンの区切り文字を渡す。この2点だけで、特殊文字まわりの事故はほぼ防げます。置換文字列には効かないので、そちらは preg_replace_callback に逃がす。個人的には、このあたりを癖にしておくと、あとからの見直しがだいぶ楽になる気がしています。

よくある質問

Q. preg_quoteの第2引数は省略してもいいですか?
A. 省略しても動きますが、区切り文字はエスケープされません。パターンの区切りに使っている文字が入力に含まれると壊れるので、区切り文字を必ず渡すのが安全です。

Q. preg_quoteはSQLインジェクションの対策になりますか?
A. なりません。正規表現の特殊文字をエスケープするだけの関数です。SQLにはプリペアドステートメントを使ってください。

Q. 日本語(マルチバイト)の文字列をpreg_quoteに渡しても大丈夫ですか?
A. 特殊文字はASCIIなので、日本語はそのまま残ります。パターン側にuフラグを付けておけば、UTF-8の文字列として扱われます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です