概要
Statement に文字列連結で値を埋め込む書き方は、SQL インジェクションという深刻なセキュリティリスクを抱えています。PreparedStatement を使えば、SQL とパラメータが分離されるため、悪意ある入力がそのまま SQL として解釈される事態を防げます。セキュリティだけでなく、DB 側で SQL の実行計画をキャッシュできるため、同じ構造のクエリを繰り返す場合にパフォーマンス面でも有利です。この記事では、基本的なパラメータバインドの書き方、複数件を一括処理するバッチ INSERT、そして SQL インジェクションがなぜ危険なのかを具体例で示します。Statement からの移行ポイントも整理するため、既存コードの改善にも役立ちます。
使いどころ
ユーザーが入力した検索条件で従業員を絞り込む画面を SQL インジェクション対策付きで実装する
CSV ファイルから読み取った数百件のデータを executeBatch でまとめて DB に取り込む
ログイン認証でユーザー名・パスワードを WHERE 条件に使う際に安全なクエリを組み立てる
コード例
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class PreparedStatementSample {
record User(int id, String username, String email, int age) {}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(
"jdbc:h2:mem:preptest;DB_CLOSE_DELAY=-1", "sa", "");
}
public static void setup(Connection conn) throws SQLException {
try (var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY,
username VARCHAR(50),
email VARCHAR(100),
age INT
)
""");
stmt.execute("DELETE FROM users");
}
}
public static int insertUser(Connection conn, User user) throws SQLException {
var sql = """
INSERT INTO users (id, username, email, age)
VALUES (?, ?, ?, ?)
""";
try (var pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, user.id());
pstmt.setString(2, user.username());
pstmt.setString(3, user.email());
pstmt.setInt(4, user.age());
return pstmt.executeUpdate();
}
}
public static List<User> findByAge(Connection conn, int minAge)
throws SQLException {
var results = new ArrayList<User>();
var sql = "SELECT id, username, email, age FROM users"
+ " WHERE age >= ? ORDER BY age";
try (var pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, minAge);
try (var rs = pstmt.executeQuery()) {
while (rs.next()) {
results.add(new User(
rs.getInt("id"), rs.getString("username"),
rs.getString("email"), rs.getInt("age")));
}
}
}
return results;
}
public static int[] batchInsert(Connection conn, List<User> users)
throws SQLException {
var sql = """
INSERT INTO users (id, username, email, age)
VALUES (?, ?, ?, ?)
""";
try (var pstmt = conn.prepareStatement(sql)) {
for (var user : users) {
pstmt.setInt(1, user.id());
pstmt.setString(2, user.username());
pstmt.setString(3, user.email());
pstmt.setInt(4, user.age());
pstmt.addBatch();
}
return pstmt.executeBatch();
}
}
public static void main(String[] args) throws SQLException {
try (var conn = getConnection()) {
setup(conn);
insertUser(conn, new User(1, "tanaka", "[email protected]", 28));
insertUser(conn, new User(2, "suzuki", "[email protected]", 35));
insertUser(conn, new User(3, "sato", "[email protected]", 22));
System.out.println("=== age >= 25 ===");
for (var user : findByAge(conn, 25)) {
System.out.printf("id=%d username=%s age=%d%n",
user.id(), user.username(), user.age());
}
var batch = List.of(
new User(10, "yamada", "[email protected]", 30),
new User(11, "ito", "[email protected]", 27));
var counts = batchInsert(conn, batch);
System.out.println("バッチ件数: " + counts.length);
}
}
}Version Coverage
テキストブロック(""")で複数行の SQL を読みやすく書ける。record でバインド対象のデータを型安全に保持できる。
// Java 17: テキストブロック + record で簡潔に
record User(int id, String username, String email, int age) {}
var sql = """
INSERT INTO users (id, username, email, age)
VALUES (?, ?, ?, ?)
""";
try (var pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, user.id());
pstmt.setString(2, user.username());
pstmt.setString(3, user.email());
pstmt.setInt(4, user.age());
pstmt.executeUpdate();
}Library Comparison
注意点
プレースホルダ(?)のインデックスは 1 始まり。0 を指定すると SQLException になるため、setInt(0, ...) は典型的なバグ
IN 句(WHERE id IN (?, ?, ?))のプレースホルダ数は動的に生成する必要がある。件数に応じて ? を連結するヘルパーを用意するとよい
executeBatch の戻り値は各行の更新件数配列だが、ドライバによっては SUCCESS_NO_INFO(-2)を返す場合がある
PreparedStatement は再利用を前提に設計されている。ループ内で毎回 prepareStatement() を呼ぶのは非効率。ループの外で1回だけ prepare する
FAQ
ユーザー入力を含む SQL は必ず PreparedStatement を使います。DDL やリテラルのみの SQL は Statement でも構いません。
ドライバにより挙動が異なります。一般的には BatchUpdateException が発生し、成功した行の情報が配列で取れます。
いいえ。Connection と同様にスレッド間で共有するものではありません。スレッドごとに取得してください。