Kodokon kodokon.com

PDO:プリペアドステートメントとトランザクション

本番と同じようにPDOを設定し、SQLインジェクションを構造的に排除し、トランザクションで書き込みを信頼できるものにしましょう。

10 分 · 3 問

このレッスンを Kodokon で開く

PDOは、データベースへのアクセスを統一するインターフェースです。MySQLでも、PostgreSQLでも、SQLiteでも、同じAPIで操作します。三つの設定が、素朴な接続と本番の接続を分けます。ERRMODE_EXCEPTIONはすべてのSQLエラーを、黙って失敗させるのではなく例外として投げさせます。FETCH_ASSOCをデフォルトにすればきれいな結果が得られます。そしてEMULATE_PREPARESfalseにすれば、サーバーが実際に実行するプリペアドステートメントになります。charset=utf8mb4はDSNに直接加えましょう。それを宣言できる唯一の信頼できる場所です。

PHP
<?php

declare(strict_types=1);

$dsn = 'mysql:host=localhost;dbname=shop'
    . ';charset=utf8mb4';

$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
];

$pdo = new PDO($dsn, 'app_user', 'secret', $options);
本番の接続:例外、連想配列でのフェッチ、ネイティブなプリペア

プリペアドステートメントは、SQLインジェクションに対する唯一の構造的な防御です。原理はこうです。クエリの構造が、:email?というプレースホルダーとともに、まずサーバーに送られます。データはそのあと別々に続き、決してSQLとして解釈されません。変数をクエリに連結することは、たとえ「エスケープ」したとしても、プロとしての誤りであり続けます。名前付きプレースホルダーはコードを読みやすくし、位置指定のプレースホルダーは短いクエリには十分です。

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$insert = $pdo->prepare(
    'INSERT INTO users (email) VALUES (:email)'
);
$insert->execute(['email' => 'lea@example.com']);

$query = $pdo->prepare(
    'SELECT id, email FROM users
     WHERE email = :email'
);
$query->execute(['email' => 'lea@example.com']);

var_dump($query->fetch(PDO::FETCH_ASSOC));
それだけで完結するSQLiteの例:プリペア、バインド、実行

トランザクションは原子性を保証します。すべての書き込みが成功するか、さもなければ一つも成功しないか、のどちらかです。定石のパターンはこうです。beginTransaction()tryの中で作業、ブロックの終わりでcommit()catchの中でrollBack()。そのうえで例外を再スローします。失敗を隠すのは、失敗そのものより悪いからです。トランザクションは短く保ちましょう。ネットワーク呼び出しや長い処理のあいだ保持されるロックは、アプリケーション全体の並行性を下げてしまいます。

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE accounts (
        id INTEGER PRIMARY KEY,
        balance INTEGER NOT NULL
    )'
);
$pdo->exec(
    'INSERT INTO accounts (balance)
     VALUES (100), (20)'
);

$pdo->beginTransaction();

try {
    $debit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance - :n
         WHERE id = :id'
    );
    $debit->execute(['n' => 50, 'id' => 1]);

    $credit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance + :n
         WHERE id = :id'
    );
    $credit->execute(['n' => 50, 'id' => 2]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

echo 'Transfer done';
アトミックな送金:すべてが成功するか、すべてがロールバックされるか

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. プリペアドステートメントは、なぜSQLインジェクションを防ぐのですか?
    • 文字列の中のアポストロフィを自動的にエスケープするから
    • SQLの構造とデータが別々に運ばれ、データが決してSQLコードとして解釈されないから
    • PHPとサーバーのあいだでクエリを暗号化するから
  2. プリペアドステートメントの:paramプレースホルダーには、何を渡せますか?
    • 値:文字列、数値、またはnull
    • テーブル名やカラム名
    • 完全なORDER BY句
  3. 定石のトランザクションのパターンで、catchブロックでは何をしますか?
    • 成功した分を保存するためにcommit()する
    • rollBack()し、そのあと例外を再スローする
    • 何もしない。トランザクションはひとりでに期限切れになる