JavaのPreparedStatementを使った安全なSQLクエリ実行方法

Javaでユーザー入力を使ってSQLを実行するなら、値を文字列連結せず、PreparedStatementの?にバインドします。ただし、これだけで接続の解放、トランザクション、動的な列名まで安全になるわけではありません。本記事では、基本のINSERT・SELECT・UPDATE・DELETEから、NULL、生成キー、バッチ処理、動的検索まで、実装時につまずきやすい点を解説します。

目次

PreparedStatementとは

PreparedStatementは、SQLの構造を先に決め、値を後から設定して実行するJDBCのインターフェースです。例えばWHERE email = ?の?にメールアドレスを設定すれば、引用符を含む文字列も値として扱われます。値の部分を手作業でSQL文字列へ埋め込む必要はありません。

同じSQLを異なる値で再実行できます。ただし、サーバー側でいつ準備・解析されるか、実行計画がどの程度再利用されるかはDB、JDBCドライバー、設定に依存します。「必ず事前コンパイルされ、必ず高速になる」とは限りません。参照:Java SE 25 PreparedStatement API。

PreparedStatementの基本的な使い方

サンプルの前提と接続の所有者

以下の例はJava 8以降の標準JDBC APIを使います。利用するDBに対応したJDBCドライバーと、事前に作成したテーブルが必要です。usersにはid(生成される数値キー)、name、email、age、productsにはproduct_id、name、price(DECIMALなど)、quantityがある想定です。実際の列型、NULL可否、制約は自分のスキーマに合わせてください。

コードのメソッドは同じクラスの中に置き、次のimportをそのクラスの前に記述します。Connection connを引数に取るメソッドは、呼び出し元の接続を借りるだけです。自分が作成したPreparedStatementとResultSetを閉じますが、接続のclose・commit・rollback・自動コミット変更は行いません。トランザクションの境界は呼び出し元が決めます。

import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import javax.sql.DataSource;

INSERTの最小例

static int insertUser(Connection conn, String name, String email, int age)
        throws SQLException {
    String sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, name);
        ps.setString(2, email);
        ps.setInt(3, age);
        return ps.executeUpdate();
    }
}

?の位置は左から1、2、3と数えます。name = '?'のように引用符で囲むとプレースホルダーではなく文字列になります。値を設定した後は、引数なしのexecuteUpdate()を呼びます。SQL文字列を渡すStatement用の実行メソッドと混同しないでください。

DataSourceから取得した接続を閉じる

static int registerSampleUser(DataSource dataSource) throws SQLException {
    // 独立したローカル処理用のDataSource。外部管理の取引には使わない。
    try (Connection conn = dataSource.getConnection()) {
        if (!conn.getAutoCommit()) {
            throw new SQLException("この例にはautoCommit=trueの接続が必要です");
        }
        return insertUser(conn, "Alice", "[email protected]", 25);
    }
}

DataSourceはアプリケーションの設定・DIなどで用意します。接続先と認証情報をソースに直書きせず、秘密情報の管理機構を利用し、DBアカウントには必要な権限だけを与えてください。JDBCの新規接続は通常autoCommit=trueですが、プールやフレームワークの設定が優先されるため、上の例では前提を確認しています。この単一INSERTは自動コミットです。複数操作を一括で確定する例は後述します。

try-with-resourcesは正常終了でも例外でもcloseを呼びます。プールから取得した接続なら、通常は物理切断ではなくプールへの返却になります。ただし、closeは業務トランザクションのcommit・rollbackの代わりではありません。外部のトランザクション管理機構から渡された接続には、その機構の取得・返却規約を適用します。参照:try-with-resourcesの公式解説、Connection API。

パラメータの設定方法

列の型に対応するsetterを使う

  • 文字列:setString。数値に見える文字列でも、文字列列の値なら文字列として設定します。
  • 整数:setInt、大きな整数:setLong。
  • 近似的な浮動小数点:setDouble。金額など正確な小数:BigDecimalとsetBigDecimalを検討します。
  • 日付:setDateにはjava.sql.Dateを渡します。JDBC 4.2対応ドライバーではsetObjectによるjava.time型のマッピングも利用できますが、対応するSQL型を確認してください。
  • SQL NULL:setNull(index, Types.INTEGER)などでSQL型を明示します。setObjectでも型を明示する方法があります。
static int insertProduct(Connection conn) throws SQLException {
    String sql = "INSERT INTO products (name, price, quantity) VALUES (?, ?, ?)";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, "Laptop");
        ps.setBigDecimal(2, new BigDecimal("999.99"));
        ps.setInt(3, 10);
        return ps.executeUpdate();
    }
}

new BigDecimal("999.99")のように文字列から作ると、doubleの二進浮動小数点表現による誤差を持ち込みません。最終的な精度・桁数・丸め方は列定義と業務仕様でも確認します。setString(1, "25")を整数列の比較に使った場合は、DBやドライバーが変換して実行できることもあり、必ずエラーになるわけではありません。暗黙変換に依存せず、列に適した型を選びます。

NULLと0、未指定を区別する

static int updateNullableAge(Connection conn, long userId, Integer age)
        throws SQLException {
    String sql = "UPDATE users SET age = ? WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        if (age == null) {
            ps.setNull(1, Types.INTEGER);
        } else {
            ps.setInt(1, age);
        }
        ps.setLong(2, userId);
        return ps.executeUpdate();
    }
}

この例でnullを渡すことは「年齢を変更しない」ではなく「SQL NULLを保存する」という意味です。列がNOT NULLなら失敗します。また、WHERE age = ?へNULLを設定してもNULL行は一致しません。NULL行を検索する場合は、固定のSQL断片age IS NULLを選びます。

パラメータの順番と再利用

SQL内のすべてのプレースホルダーに、対応する順番と型で値を設定します。値は次回の実行まで保持されるため、同じPreparedStatementを再利用するときは、変更され得る値を毎回すべて設定します。clearParameters()は任意のリセット手段であり、呼んだ後は必要な値を改めて設定する必要があります。

セキュリティ上の利点

SQLの構造と値を分離する

例えばユーザー入力を"WHERE name = '" + userInput + "'"のように連結すると、引用符やSQL構文によって検索条件が変わるおそれがあります。PreparedStatementを作る前に値を連結してしまえば、その後でprepareしても安全にはなりません。

static List<Long> findIdsByName(Connection conn, String userInput)
        throws SQLException {
    String sql = "SELECT id FROM users WHERE name = ?";
    List<Long> ids = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, userInput);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                ids.add(rs.getLong("id"));
            }
        }
    }
    return ids;
}

バインドした値はSQLの構造ではなくデータとして扱われます。「すべての特殊文字を一律にエスケープする機能」と理解するより、SQLと値を分ける仕組みと捉えるのが適切です。アプリケーション側でシングルクォートを二重化してからsetStringする必要はなく、余計な加工は値を変える原因になります。参照:OracleのPrepared Statements解説。

列名・テーブル名・並べ替え方向は値ではない

?に設定できるのは値です。テーブル名や列名、ASC・DESC、SQL句全体を置き換えることはできません。ORDER BY ?に列名文字列を渡しても、その列による並べ替えにはなりません。必要なSQL断片をアプリケーション側の許可リストから選び、利用者が送った文字列をそのまま連結しないでください。参照:MySQL PREPAREのパラメータマーカー規則。

パラメータ化は認可、入力の長さ・範囲の検証、最小権限、機密データのログ抑制を代替しません。「値として安全に渡せること」と「その利用者にそのデータを見せてよいこと」は別問題です。

LIKE検索で%と_を文字として扱う

LIKE ?も値はバインドできますが、値に含まれる%と_はLIKEのワイルドカードとして働きます。利用者が入力した文字列をそのまま部分一致で探したいなら、SQLインジェクション対策とは別にLIKE用のエスケープが必要です。次の例は!をエスケープ文字にするDB用です。

static String escapeLikeLiteral(String input) {
    Objects.requireNonNull(input, "input");
    return input.replace("!", "!!")
                .replace("%", "!%")
                .replace("_", "!_");
}
static List<Long> findIdsContainingName(Connection conn, String input)
        throws SQLException {
    String sql = "SELECT id FROM users WHERE name LIKE ? ESCAPE '!'";
    List<Long> ids = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, "%" + escapeLikeLiteral(input) + "%");
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                ids.add(rs.getLong("id"));
            }
        }
    }
    return ids;
}

最初に!自身を二重化してから%と_を処理します。例えば50%_off!は%50!%!_off!!%になります。空文字は%%となって広く一致するため、検索を許可するかは別途検証します。この例のESCAPE構文、照合順序、大文字・小文字の扱いは対象DBで確認してください。参照:PostgreSQLのLIKE・ESCAPE仕様。

SQLクエリの実行と結果の処理

INSERTとexecuteUpdateの戻り値

冒頭のinsertUserはexecuteUpdate()の更新件数を返します。INSERT・UPDATE・DELETEなどのDMLでは件数を確認できますが、それは生成されたIDではありません。UPDATEで一致した行と実際に変更された行のどちらを数えるかなどはDB・ドライバー設定にも注意してください。DBが返す件数を業務上の成功条件と照らし合わせます。

SELECTとResultSetの読み取り

static List<Integer> findAgesByEmail(Connection conn, String email)
        throws SQLException {
    String sql = "SELECT age FROM users WHERE email = ?";
    List<Integer> ages = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, email);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                int value = rs.getInt("age");
                ages.add(rs.wasNull() ? null : value);
            }
        }
    }
    return ages;
}

ResultSetは最初の行の手前から始まるため、next()で行へ移動します。getIntはSQL NULLでも0を返すため、NULLを区別するなら、その取得直後にwasNull()を確認します。例では必要な列だけSELECTし、読み取った値をJava側のリストへ移してから結果セットを閉じています。大量の結果では全件をリストに蓄積せず、ページ分割や逐次処理も検討します。参照:ResultSet API。

UPDATE・DELETEとWHERE条件

static int updateAge(Connection conn, long userId, int age)
        throws SQLException {
    try (PreparedStatement ps = conn.prepareStatement(
            "UPDATE users SET age = ? WHERE id = ?")) {
        ps.setInt(1, age);
        ps.setLong(2, userId);
        return ps.executeUpdate();
    }
}

static int deleteUser(Connection conn, long userId) throws SQLException {
    try (PreparedStatement ps = conn.prepareStatement(
            "DELETE FROM users WHERE id = ?")) {
        ps.setLong(1, userId);
        return ps.executeUpdate();
    }
}

パラメータ化しても、WHERE条件の欠落や誤ったIDは防げません。削除・更新の対象、権限、想定件数を検証してください。0件は対象が存在しない等の可能性があり、常にSQLExceptionになるわけではありません。件数を見て取り消したい処理は、最初から呼び出し元がトランザクションで包みます。自動コミット後の件数チェックでは既に確定した変更をrollbackできません。

自動生成されたキーを取得する

static long insertUserReturningId(Connection conn, String name, String email,
                                  int age) throws SQLException {
    String sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)";
    try (PreparedStatement ps = conn.prepareStatement(
            sql, Statement.RETURN_GENERATED_KEYS)) {
        ps.setString(1, name);
        ps.setString(2, email);
        ps.setInt(3, age);
        if (ps.executeUpdate() != 1) {
            throw new SQLException("INSERTの更新件数が想定と異なります");
        }
        try (ResultSet keys = ps.getGeneratedKeys()) {
            if (!keys.next()) {
                throw new SQLException("生成キーを取得できませんでした");
            }
            long id = keys.getLong(1);
            if (keys.wasNull()) {
                throw new SQLException("生成キーがNULLでした");
            }
            return id;
        }
    }
}

これは単一の数値生成キーを返すテーブル・ドライバーを想定した例です。サポート状況、生成キーの列・型、複数行やバッチでの戻り方はDBとドライバーに依存します。必要ならprepareStatement(sql, new String[]{"id"})による列指定やDB固有のRETURNING構文を検討してください。getGeneratedKeys()の結果セットも閉じます。参照:Connection.prepareStatementの生成キー指定、Statement.getGeneratedKeys。

キー取得に失敗しても、INSERTそのものが失敗したとは限りません。特にautoCommit=trueでは挿入が確定済みの場合があります。無条件にINSERTを再試行しないでください。キーの検証を含めて一括確定したい場合は、呼び出し元の明示的なトランザクション内で実行します。返されたIDだけでは、まだcommitされていない変更の確定を意味しません。

複数パラメータの扱い

複数の条件をバインドする

static List<Long> findIdsByNameAndAge(Connection conn, String name, int age)
        throws SQLException {
    String sql = "SELECT id FROM users WHERE name = ? AND age = ?";
    List<Long> ids = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, name);
        ps.setInt(2, age);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                ids.add(rs.getLong("id"));
            }
        }
    }
    return ids;
}

複数条件で更新する

static int updateAgeByNameAndEmail(Connection conn, int age,
                                   String name, String email) throws SQLException {
    String sql = "UPDATE users SET age = ? WHERE name = ? AND email = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setInt(1, age);
        ps.setString(2, name);
        ps.setString(3, email);
        return ps.executeUpdate();
    }
}

条件が複数あっても、必ず1行だけになるとは限りません。名前とメールが一意であることは、SQL文ではなく実際の制約・データで決まります。1人を更新する用途では主キーなどの一意な識別子を使う方が明確です。パラメータが多い場合は、SQLを組み立てる順番と値を設定する順番が一致するようにまとめ、テストで組み合わせを確認しましょう。

PreparedStatementを使ったバッチ処理

接続とトランザクションを所有するバッチの例

addBatch()で現在のパラメータの組を追加し、executeBatch()で実行します。バッチ化はトランザクション化と同じではありません。途中失敗で一部だけ確定させたくない場合は、トランザクション対応のDB・テーブル上で、自動コミットを無効にして全体をcommitまたはrollbackします。

次の例は独立したローカルトランザクションを自分で開始・終了するメソッドです。外部管理のトランザクションや呼び出し元から借りた接続には流用しません。プールを使う場合は、返却時の状態リセットと障害接続の破棄が適切に実装・設定されていることを前提にします。

static int[] insertSampleBatch(DataSource dataSource) throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        if (!conn.getAutoCommit()) {
            throw new SQLException("この例にはautoCommit=trueの独立した接続が必要です");
        }
        conn.setAutoCommit(false);
        try {
            int[] counts;
            String sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)";
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                String[] names = {"John", "Jane", "Bob"};
                String[] emails = {"[email protected]", "[email protected]", "[email protected]"};
                int[] ages = {28, 30, 22};
                for (int i = 0; i < names.length; i++) {
                    ps.setString(1, names[i]);
                    ps.setString(2, emails[i]);
                    ps.setInt(3, ages[i]);
                    ps.addBatch();
                }
                counts = ps.executeBatch();
                for (int count : counts) {
                    if (count == Statement.EXECUTE_FAILED) {
                        throw new SQLException("バッチに失敗した処理があります");
                    }
                }
            }
            // Statementのcloseまで正常に終わってから確定する。
            conn.commit();
            return counts;
        } catch (SQLException | RuntimeException e) {
            try {
                conn.rollback();
            } catch (SQLException rollbackError) {
                e.addSuppressed(rollbackError);
            }
            throw e;
        }
    }
}

準備・パラメータ設定・バッチ実行・statementのclose・commitを同じtryの範囲に入れているため、commitだけをcatchする例と違い、実行途中のSQLExceptionもrollbackの対象です。rollbackの失敗は元の例外を隠さないようにsuppressed例外として付加し、失敗を呼び出し元へ返します。通常のRuntimeExceptionも同様に処理しますが、プロセス停止や致命的障害からの回復を保証するものではありません。

自動コミットを安易に戻さない

setAutoCommit(true)への変更は、未完了のトランザクションをcommitすることがあります。そのため、rollbackが失敗した後にfinallyで無条件にtrueへ戻すのは危険です。上の例は接続を自分で取得して最後に閉じるため、手動でtrueに戻しません。接続を継続利用する設計なら、トランザクション終了が確認できた場合だけ元の設定へ戻し、状態不明の接続は再利用せず、プール固有の破棄・無効化手順を適用します。

接続断などでcommitが例外になった場合、DB側では確定していてクライアントだけが結果を受け取れていない可能性があります。rollbackを試みても「未実行だった」とは断定できません。一意な処理IDなどで結果を照合し、重複実行を防いでください。また、DDLの暗黙commitや非トランザクションテーブルなどは、通常のDMLのrollbackと同じ前提では扱えません。

BatchUpdateExceptionと更新件数

バッチの一部が失敗した場合はBatchUpdateExceptionが発生し得ます。これはSQLExceptionの一種なので、上の例でもrollbackの対象です。getUpdateCounts()には失敗前の結果だけが入る場合と、失敗後も続行した結果が入る場合があります。DB・ドライバー・設定によって異なります。件数配列は処理の診断情報であり、commit済みの証明ではありません。

static String describeBatchCount(int count) {
    if (count == Statement.SUCCESS_NO_INFO) {
        return "成功(件数不明)";
    }
    if (count == Statement.EXECUTE_FAILED) {
        return "失敗";
    }
    if (count >= 0) {
        return "更新件数: " + count;
    }
    return "想定外の値: " + count;
}

SUCCESS_NO_INFOは-2、EXECUTE_FAILEDは-3です。負の値をすべて更新件数として加算しないでください。intの範囲を超える件数を扱う場合は、対応するexecuteLargeBatch()とgetLargeUpdateCounts()を検討します。参照:BatchUpdateException API、Statement.executeBatch API。

大量データではバッチサイズと確定単位を分けて考える

一度に大量の行をaddBatchすると、メモリやDBのパケット・パラメータ数制限に達することがあります。一定件数ずつexecuteBatchしても、最後に1回だけcommitすれば同じトランザクションにまとめられます。一方、チャンクごとにcommitすれば先行チャンクは残ります。必要な原子性、ロック時間、メモリ、再開方法を踏まえて設計し、バッチサイズは実測で決めてください。

実行時のパフォーマンス改善

同じSQLを異なる値で再利用する

static void updateTwoPrices(Connection conn) throws SQLException {
    String sql = "UPDATE products SET price = ? WHERE product_id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setBigDecimal(1, new BigDecimal("99.99"));
        ps.setLong(2, 1L);
        ps.executeUpdate();

        ps.setBigDecimal(1, new BigDecimal("149.99"));
        ps.setLong(2, 2L);
        ps.executeUpdate();
    }
}

再利用するのはSQLの形で、値まで同じにする必要はありません。毎回の実行は残るため、再利用だけで通信回数が必ず減るわけではありません。この例の2回の更新を一括確定する必要があるなら、呼び出し元のトランザクションで包みます。同じConnectionやPreparedStatementを複数スレッドで同時に使い回す設計は避けてください。

サーバー側prepare・バッチ・プールは別の仕組み

例えばpgJDBCにはサーバー側prepared statementへ切り替えるprepareThresholdがあり、既定値は5です。MySQL Connector/JにはuseServerPrepStmtsやcachePrepStmtsなどの設定があります。これらの有無と、アプリケーションがPreparedStatementで値をバインドすることは分けて考えます。設定を一律に変更せず、利用中のバージョンの仕様と測定結果を確認してください。参照:pgJDBC Server Prepared Statements、MySQL Connector/J設定一覧。

バッチ化はドライバーによって通信や実行をまとめられる場合がありますが、executeBatch()が必ず単一のSQL・単一の通信になるとは限りません。接続プールは接続確立のコストを抑える仕組みで、SQLの改善やトランザクション設計の代わりにはなりません。DataSource自体が必ずプールであるわけでもありません。プールから借りた接続は短く使い、確実に返却します。

インデックス・取得量・トランザクションを実測する

  • 検索条件、結合、並べ替えに合ったインデックスと実行計画を確認する。書き込みコストもあるため、むやみに索引を増やさない。
  • 必要な列と行だけ取得する。大量結果のページング、fetch sizeやquery timeoutの対応・効果はドライバーで確認する。
  • 長時間のトランザクションや不要な待機を避ける。ただし高速化のために必要な原子性を崩さない。
  • 同じ負荷・データ量で、応答時間、DB処理時間、ロック、接続待ちを測る。PreparedStatementへの置き換えだけで高速化を断定しない。

応用例:動的SQLクエリの実装

条件に応じて固定のSQL断片を追加する

入力に応じてWHERE条件が変わる場合は、SQL断片だけをコードで選び、値は引き続きバインドします。次の例ではnameとemailのnull・空文字を「条件なし」、ageのnullを「条件なし」と定義します。age=0は有効な条件です。全条件なしなら全件検索になるため、権限、上限件数やページングは呼び出し元で別途制御してください。

static List<Long> findIdsDynamic(Connection conn, String name, String email,
                                  Integer age) throws SQLException {
    boolean hasName = name != null && !name.isEmpty();
    boolean hasEmail = email != null && !email.isEmpty();
    boolean hasAge = age != null;
    StringBuilder sql = new StringBuilder("SELECT id FROM users WHERE 1=1");
    if (hasName) sql.append(" AND name = ?");
    if (hasEmail) sql.append(" AND email = ?");
    if (hasAge) sql.append(" AND age = ?");

    List<Long> ids = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql.toString())) {
        int index = 1;
        if (hasName) ps.setString(index++, name);
        if (hasEmail) ps.setString(index++, email);
        if (hasAge) ps.setInt(index++, age);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) ids.add(rs.getLong("id"));
        }
    }
    return ids;
}

条件を追加するときとsetterを呼ぶときに同じ判定を使うため、順番のずれを防ぎやすくなります。可変個のIN条件でも、要素数分の?だけを生成し、値を一つずつバインドします。IN (?)へカンマ区切りの文字列を渡しても、複数の値には展開されません。空リストやDBのパラメータ数上限も考慮してください。

動的なカラム選択と許可リスト

static List<String> findSelectedFields(Connection conn, long userId,
                                       boolean includeAge) throws SQLException {
    // 連結するSQLはコード内の固定文字列だけ。
    String columns = includeAge ? "name, email, age" : "name, email";
    String sql = "SELECT " + columns + " FROM users WHERE id = ?";
    List<String> values = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setLong(1, userId);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                values.add(rs.getString("name"));
                values.add(rs.getString("email"));
                if (includeAge) {
                    int age = rs.getInt("age");
                    values.add(rs.wasNull() ? null : Integer.toString(age));
                }
            }
        }
    }
    return values;
}

この例の列名は固定の選択肢なので、入力文字列をSQLに直接差し込みません。実務では結果を専用のクラスへ格納すると扱いやすくなります。並べ替え列を画面から指定する場合も、次のように受け取ったキーを固定のSQL列名へ変換し、不明な値は拒否します。

static String orderColumn(String requested) {
    if ("name".equals(requested)) return "name";
    if ("age".equals(requested)) return "age";
    if ("id".equals(requested)) return "id";
    throw new IllegalArgumentException("許可されていない並べ替え項目です");
}

列名の許可リストに加え、昇順・降順もbooleanや列挙型から固定のASC・DESCを選びます。引用符を付けただけの任意の識別子や、画面から受け取ったSQL断片は許可リストの代わりになりません。動的SQLが複雑になる場合は、条件とパラメータの対応をテストし、必要に応じてクエリビルダーなどを検討します。

よくある間違いとトラブルシューティング

1. プレースホルダー・列数・設定数が合わない

INSERT INTO users (name, email) VALUES (?, ?, ?)は列数と値の数が不一致です。一方、正しいSQLでも必要なパラメータが未設定なら実行時に失敗します。prepare時と実行時のどちらで検出されるかはドライバーによります。?を引用符で囲んでいないか、インデックスを0から始めていないか、SQL構造とsetterの対応を確認します。

2. 型・NULL・入力範囲を混同する

数字の文字列が暗黙変換できても、性能や移植性の問題になることがあります。列型に合うsetterを使い、桁あふれ、DECIMALの精度、文字列長、NOT NULLや外部キー制約も確認してください。日付・時刻ではタイムゾーンを持つ型かどうかも重要です。型の選択だけで業務上の妥当性は保証されません。

3. 再利用時に以前のパラメータが残る

1回目に名前と年齢を設定し、2回目に名前だけ変えると、年齢は前回の値が使われます。これは必ずエラーになるケースではなく、意図せず古い値で成功する危険があります。毎回必要な全値を設定することを基本とし、必要ならclearParameters後に全値を設定します。バッチでもaddBatchの前に各行の値をすべて設定してください。

4. 正常系だけでcloseする

末尾にps.close()を書くのみでは、途中で例外になると実行されません。try-with-resourcesを使い、値を設定してからResultSetを取得する順番にします。先にResultSetを開いてからパラメータを設定しても遅すぎます。借りたConnectionは勝手に閉じず、自分で取得した接続だけを契約に従って返却します。

5. rollbackを忘れる・例外を握りつぶす

複数更新を一括で扱う場合は、どの層がトランザクションを所有するかを先に決めます。Spring等の管理下で手動commit・rollbackを混在させないでください。SQLExceptionを表示するだけで成功扱いにせず、呼び出し元へ伝播するか失敗として扱います。バッチの一部成功、rollbackの失敗、commit結果不明も区別します。

6. ログから原因を調べる

調査時はSQLState、ベンダーエラーコード、getNextException()の例外チェーン、suppressed例外、バッチ件数を確認します。ただし例外メッセージにSQLや値が含まれる場合があるので、接続パスワード、入力値、メールアドレスなどを無加工でログ出力しないでください。利用者には内部情報を含まないエラーを返し、権限のある運用ログで原因を確認します。

まとめ

PreparedStatementの基本は、SQLの構造を固定し、値を?へ適切な型でバインドすることです。動的な識別子は許可リストで制御し、LIKEのワイルドカード、NULL、更新件数、生成キーの意味を区別します。自分が作ったリソースは確実に閉じ、トランザクションの所有者だけがcommit・rollbackを行います。

本記事のサンプルは構文と制御フローの説明用です。実際のDBに適用する前に、テスト用環境でドライバーの対応、テーブル制約、生成キー、バッチ途中失敗、接続断時の挙動を確認してください。パラメータ化の安全性と、DB固有の実行結果・性能は分けて検証することが重要です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次