【教育コンテンツ】PHPで正の整数を受け取るバリデーションについて
― バリデーションの落とし穴と実用解法まとめ ―
前書き
毎回説明するのが大変なので、記事にしました。
これ以外にも$argv[1]をつかうなら、issetをつかうんだ、とかありますが、それは別の話として、$inputが整数を期待する入力(の何か)だと思ってください
PHP以外でも多くの言語では入力は文字列です(JSONとかをデコードしたときを除く)。別にPHPではなくても役立つかもしれません。が、サンプルはPHPです。
本題
PHPで外部から「アイテムID」などの数値を受け取る際、入力値の検証は地味ながらも非常に重要です。
外部からの入力(例えば、$_POST や $_GET で受け取る値、のみならず、CLIの$argvも)はすべて文字列として扱われるため、単純なキャストだけでは意図しない動作を招く恐れがあります。
たとえば、以下のようなコードは危険です。
if (!is_int((int)$input)) {
    // エラー処理
}
なぜなら、(int)$input は '123abc' のような入力も 123 として通ってしまうからです。
そこで、本記事では**正の整数(0以外)**を安全に判定するための実用的な3つの方法と、その検証方法(ユニットテスト)を紹介します。
方法①:正規表現によるバリデーション
最も厳密にチェックできる方法は、正規表現を使うことです。
ここでは、先頭に「0」が来ることを許さず、1以上の整数のみを許容する場合、UTF-8フラグ付きで次のように記述します。
if (!preg_match('/\A[1-9][0-9]*\z/u', $input)) {
    echo "エラー: 1以上の整数を入力してください。";
    exit;
}
- 
\A:文字列の先頭(^をつかうときは、Multi lineを注意) - 
[1-9]:1~9のいずれかの数字で始まる - 
[0-9]*:続く数字は0個以上(\dという手もある) - 
\z:文字列の末尾($をつかうときは、Multi lineを注意) - 
/u:UTF-8フラグ(マルチバイト文字を正しく扱うため、このケースでは非必須かもだが、ある方が無難と思います) 
この方法なら、たとえば '123' は許容され、'0123'、'123abc'、'0'、'' などはすべて弾かれます。
 方法②:ctype_digit や is_numeric を利用した方法
PHPには、文字列がすべて数字かどうかを判定する ctype_digit() 関数があります。
ただし、この関数は '0123' のような先頭にゼロがある文字列も数字として認識するため、0チェックは別途必要です。
if (!ctype_digit($input) || $input === '0') {
    echo "エラー: 1以上の整数を入力してください。";
    exit;
}
この方法はシンプルで読みやすく、かつ高速に判定できます。
方法③:キャスト+文字列比較(おじさんテク)
昔ながらの方法ですが、キャスト+文字列比較によるバリデーションも実用的です。
この方法では、入力をまず整数にキャストし、再び文字列に戻した結果と元の入力を比較します。
また、(int)$input !== 0 により、0が入力された場合も除外します。
if ((int)$input !== 0 && (string)(int)$input === $input) {
    // $input は正の整数と判定される
} else {
    echo "エラー: 1以上の整数を入力してください。";
    exit;
}
この条件式のポイントは以下の通りです:
- 
(int)$input !== 0により、'0'や空文字、また数値に変換できない場合(結果が0になる)を除外 - 
(string)(int)$input === $inputにより、例えば'123abc'や先頭にゼロがある場合(例:'0123'は123に変換される)を弾く 
ユニットテストでバリデーションを検証しよう
各手法の挙動を確認するため、PHPUnit を使ったユニットテストで実際の動作をチェックしてみましょう。
以下は、さまざまな入力値に対して3つの方法を検証するサンプルコードです。
use PHPUnit\Framework\TestCase;
class PositiveIntegerValidationTest extends TestCase
{
    public function inputProvider()
    {
        return [
            ['123', true],
            ['0', false],
            ['-123', false],
            ['123.45', false],
            ['abc', false],
            ['12a3', false],
            ['', false],
            [' 123', false],
            ['123 ', false],
            ['0123', false], // 仕様によっては、'0123'を許容する場合はテストケースを調整してください
        ];
    }
    /**
     * @dataProvider inputProvider
     */
    public function testRegex($input, $expected)
    {
        $result = preg_match('/\A[1-9][0-9]*\z/u', $input) === 1;
        $this->assertSame($expected, $result);
    }
    /**
     * @dataProvider inputProvider
     */
    public function testCtypeDigit($input, $expected)
    {
        $result = ctype_digit($input) && $input !== '0';
        $this->assertSame($expected, $result);
    }
    /**
     * @dataProvider inputProvider
     */
    public function testCastCompare($input, $expected)
    {
        $result = (int)$input !== 0 && (string)(int)$input === $input;
        $this->assertSame($expected, $result);
    }
}
このテストを実行して、各手法が期待通りに動作するか確認してください。
テストにパスしなければ、バリデーションに問題がある可能性が高いです。
まとめ
| 方法 | わかりやすさ | 安全度 | 
|---|---|---|
| ノーガード | ????? | ★☆☆☆☆ | 
| intにキャスト | ★★☆☆☆ | ★★☆☆☆ | 
| 正規表現 | ★★☆☆☆ | ★★★★★ | 
ctype_digit | 
★★★★★ | ★★★★☆ | 
| キャスト比較 | ★★★☆☆ | ★★★★☆ | 
- 
正規表現
厳密なチェックが可能で、UTF-8対応もしているため、多言語環境でも安全に動作します。ただし、正規表現自体の記述がやや複雑です。 - 
ctype_digit,is_numeric
シンプルで直感的なコードが書けますが、先頭ゼロをそのまま許容してしまう点に注意が必要です。 - 
キャスト比較
昔ながらのおじさんテクで、条件式だけで手軽に検証できますが、先頭ゼロの扱いや意図の明示が難しい場合があります。 
最後に
外部からの入力を適切にバリデーションすることは、システム全体の安全性と信頼性を守るために不可欠です。
今回紹介した3つの方法とユニットテストを活用し、自分のコードが仕様通りに動作しているか、ぜひ確認してください。
バリデーションの正確性が、後々のバグ防止やセキュリティ向上に直結することを実感していただけるはずです。
あと、重要なんですが「実際にうごかして試してみた?」です。UnitTestで担保するでもいいですし、自分でためしてみて挙動を確認するでもよいですが、試してないでコピペするのはやめよう!!!!!!!!!!!!!!!!!!!!!!!!
Discussion