JDBCのPreparedStatementに名前付きパラメタ

これは何?

JDBCのPreparedStatementで名前付きパラメタを使えるようにするクラスNamedParameterPreparedStatementです。

GitHubにも置いてあります。リポジトリは「JDBCのResultSetからクラスにマッピング」と共有しています。

https://github.com/kmaebashi/dbutil

JDBCでPreparedStatementを使う時は、プレースホルダには「?」しか使えず、何番目の「?」なのかを数えないといけませんが、 「:PARAM_NAME」のようにコロンで始まる名前付きパラメタを使えるようにします。 C#あたりだと昔から使えますし、Spring FrameworkのNamedParameterJdbcTemplateと同様の機能です。

こんな感じで使います。

// 名前付きパラメタを含むSQLを書く。
String sql = """
  INSERT INTO USERS (
    SERIALID,
    NAME,
    ADDRESS,
    TEL
  ) VALUES (
    :SERIALID,
    :NAME,
    :ADDRESS,
    :TEL
  )
  """;
// NamedParameterPreparedStatementのインスタンス生成。
// 第1引数はjava.sql.Connection, 第2引数が名前付きパラメタを含むSQLの文字列。
NamedParameterPreparedStatement npps
            = NamedParameterPreparedStatement.newInstance(conn, sql);
// 各パラメタの名前と値の組をMapに詰め込む。
var params = new HashMap<String, Object>();
params.put("SERIALID", serialId);
params.put("NAME", name);
params.put("ADDRESS", address);
params.put("TEL", tel);
// NamedParameterPreparedStatementに対してパラメタを設定する。
npps.setParameters(params);
// NamedParameterPreparedStatementからPreparedStatementを取得し、
// 以後は好きにする。
int result = npps.getPreparedStatement().executeUpdate();

上の例にあるとおり、パラメタの値はMapで渡しますが、これで値として指定できるデータ型は以下の通りです(プリミティブ型は当然auto boxingで拡張されるとして)。

これ以外の型を設定した場合、UnsupportedTypeExceptionがスローされます。必要に応じて書き足してください。

ソース

ディレクトリ構造

com.kmaebashi以下
├─dbutil
│      NamedParameterPreparedStatement.java … 利用者に公開するインタフェース
│      ParameterValueNotFoundException.java … パラメタの設定不足エラー
│      SqlParseException.java               … SQLのパースエラー
│      UnsupportedTypeException.java        … サポート対象外の型を使ったエラー
│
└─dbutilimpl
        NamedParameterPreparedStatementImpl.java … 実装の本体
        SqlAndParams.java                        … 内部で使用するクラス

CSVパーサと同様、利用者に公開するクラスと公開しないクラスを分けています。

内容

利用者に公開するインタフェースNamedParameterPreparedStatement.javaは以下の通り。上の使い方のサンプルで使ったメソッドがそのまま並んでいます。

NamedParameterPreparedStatement.java

package com.kmaebashi.dbutil;
import java.sql.*;
import java.util.Map;

import com.kmaebashi.dbutilimpl.NamedParameterPreparedStatementImpl;

public interface NamedParameterPreparedStatement {
    public static NamedParameterPreparedStatement newInstance(Connection conn, String sql)
            throws SQLException, SqlParseException  {
        return new NamedParameterPreparedStatementImpl(conn, sql);
    }

    public void setParameters(Map<String, Object> params)
            throws SQLException, UnsupportedTypeException, ParameterValueNotFoundException;

    public PreparedStatement getPreparedStatement();
}

以下は例外です。まあ特記することはありません。

ParameterValueNotFoundException.java

package com.kmaebashi.dbutil;

public class ParameterValueNotFoundException extends Exception {
    public ParameterValueNotFoundException(String message) {
        super(message);
    }
}

SqlParseException.java

package com.kmaebashi.dbutil;

public class SqlParseException extends Exception {
    public SqlParseException(String message) {
        super(message);
    }
}

UnsupportedTypeExceptionは、NamedParameterPreparedStatementと共用しています。

UnsupportedTypeException.java

package com.kmaebashi.dbutil;

public class UnsupportedTypeException extends Exception {
    public UnsupportedTypeException(String message) {
        super(message);
    }
}

以下NamedParameterPreparedStatementImplが実装であり、本体です。

NamedParameterPreparedStatementImpl.java

package com.kmaebashi.dbutilimpl;

import java.sql.*;
import com.kmaebashi.dbutil.NamedParameterPreparedStatement;
import com.kmaebashi.dbutil.ParameterValueNotFoundException;
import com.kmaebashi.dbutil.SqlParseException;
import com.kmaebashi.dbutil.UnsupportedTypeException;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Map;

public class NamedParameterPreparedStatementImpl implements NamedParameterPreparedStatement {
    private PreparedStatement preparedStatement;
    private String[] paramNames;

    /* 基本動作:
     * コンストラクタでSQLをパースし、中に出てくる名前付きパラメタの一覧をparamNamesにセットする。
     * paramNamesにはSQL中の名前付きパラメタが登場順に格納されている。複数回登場したら、
     * 重複して格納されている。
     * setParameters()で、paramNamesを頭からループし、引数のMapから取得した値を
     * ps.setXXX()で設定する。
     */
    public NamedParameterPreparedStatementImpl(Connection conn, String sql)
            throws SQLException, SqlParseException {
        SqlAndParams sqlAndParams = parseSql(sql);
        this.paramNames = sqlAndParams.paramNames;
        this.preparedStatement = conn.prepareStatement(sqlAndParams.sql);
    }

    @Override
    public void setParameters(Map<String, Object> params)
        throws SQLException, UnsupportedTypeException, ParameterValueNotFoundException {
        setParametersImpl(this.preparedStatement, this.paramNames, params);
    }

    @Override
    public PreparedStatement getPreparedStatement() {
        return this.preparedStatement;
    }

    private enum State {
        INITIAL,
        COLON,
        IN_PARAMETER,
        COMMENT_START,
        IN_COMMENT,
        C_STYLE_COMMENT_START,
        IN_C_STYLE_COMMENT,
        C_STYLE_COMMENT_END,
        IN_STRING,
        STRING_END
    }

    static SqlAndParams parseSql(String srcSql) throws SqlParseException {
        State state = State.INITIAL;
        StringBuilder sqlSB = new StringBuilder();
        StringBuilder param = null;
        ArrayList<String> paramList = new ArrayList<String>();

        for (int i = 0; i < srcSql.length(); i++) {
            char ch = srcSql.charAt(i);

            switch (state) {
                case INITIAL:
                    if (ch == ':') {
                        state = State.COLON;
                    } else if (ch == '-') {
                        state = State.COMMENT_START;
                        sqlSB.append(ch);
                    } else if (ch == '/') {
                        state = State.C_STYLE_COMMENT_START;
                        sqlSB.append(ch);
                    } else if (ch == '\'') {
                        state = State.IN_STRING;
                        sqlSB.append(ch);
                    } else {
                        sqlSB.append(ch);
                    }
                    break;
                case COLON:
                    if (Character.isJavaIdentifierStart(ch)) {
                        param = new StringBuilder();
                        param.append(ch);
                        state = State.IN_PARAMETER;
                    } else {
                        throw new SqlParseException(":の後ろに識別子がありません。");
                    }
                    break;
                case IN_PARAMETER:
                    if (Character.isJavaIdentifierPart(ch)) {
                        param.append(ch);
                    } else {
                        paramList.add(param.toString());
                        sqlSB.append('?');
                        sqlSB.append(ch);
                        state = State.INITIAL;
                    }
                    break;
                case COMMENT_START:
                    if (ch == '-') {
                        state = State.IN_COMMENT;
                    } else {
                        state = State.INITIAL;
                    }
                    sqlSB.append(ch);
                    break;
                case IN_COMMENT:
                    if (ch == '\n') {
                        state = State.INITIAL;
                    }
                    sqlSB.append(ch);
                    break;
                case C_STYLE_COMMENT_START:
                    if (ch == '*') {
                        state = State.IN_C_STYLE_COMMENT;
                    } else {
                        state = State.INITIAL;
                    }
                    sqlSB.append(ch);
                    break;
                case IN_C_STYLE_COMMENT:
                    if (ch == '*') {
                        state = State.C_STYLE_COMMENT_END;
                    }
                    sqlSB.append(ch);
                    break;
                case C_STYLE_COMMENT_END:
                    if (ch == '/') {
                        state = State.INITIAL;
                    } else {
                        state = State.IN_COMMENT;
                    }
                    sqlSB.append(ch);
                    break;
                case IN_STRING:
                    if (ch == '\'') {
                        state = State.STRING_END;
                    }
                    sqlSB.append(ch);
                    break;
                case STRING_END:
                    if (ch == '\'') {
                        state = State.IN_STRING;
                        sqlSB.append(ch);
                    } else {
                        if (ch == ':') {
                            state = State.COLON;
                        } else if (ch == '-') {
                            state = State.COMMENT_START;
                            sqlSB.append(ch);
                        } else if (ch == '/') {
                            state = State.C_STYLE_COMMENT_START;
                            sqlSB.append(ch);
                        } else {
                            sqlSB.append(ch);
                        }
                    }
            }
        }
        return new SqlAndParams(sqlSB.toString(), paramList.toArray(new String[0]));
    }

    static void setParametersImpl(PreparedStatement ps, String[] paramNames, Map<String, Object> paramValues)
        throws SQLException, UnsupportedTypeException, ParameterValueNotFoundException {
        for (int i = 0; i < paramNames.length; i++) {
            if (!paramValues.containsKey(paramNames[i])) {
                throw new ParameterValueNotFoundException("パラメタ" + paramNames[i] + "の値が見つかりません。");
            }
            Object value = paramValues.get(paramNames[i]);
            if (value instanceof Integer intValue) {
                ps.setInt(i + 1, intValue.intValue());
            } else if (value instanceof Double doubleValue) {
                ps.setDouble(i + 1, doubleValue);
            } else if (value instanceof Boolean boolValue) {
                ps.setBoolean(i + 1, boolValue);
            } else if (value instanceof String strValue) {
                ps.setString(i + 1, strValue);
            } else if (value instanceof java.sql.Date dateValue) {
                ps.setDate(i + 1, dateValue);
            } else if (value instanceof LocalDate dateValue) {
                ps.setDate(i + 1, java.sql.Date.valueOf(dateValue));
            } else if (value instanceof java.sql.Timestamp timestampValue) {
                ps.setTimestamp(i + 1, timestampValue);
            } else if (value instanceof LocalDateTime dateTimeValue) {
                ps.setTimestamp(i + 1, java.sql.Timestamp.valueOf(dateTimeValue));
            } else if (value == null) {
                ps.setNull(i + 1, Types.NULL);
            } else {
                throw new UnsupportedTypeException("型"+ value.getClass().getName() + "はサポートしていません。"
                        + "必要に応じて書き足してください。");
            }
        }
    }
}

大筋の挙動は18行目からのコメントに記載しています。

56行目からのparseSql()メソッドで、受け取ったSQLを解析しています。この程度のものなら、正規表現か何かで「:[a-zA-Z_][a-zA-Z0-9_」を変換すればいいかとも思いましたが、結局コメントの中や文字列リテラルの中まで変換しては困るので、1文字ずつ読み込んで有限状態マシンで解析しています。有限状態マシンって何?という人は、CSVパーサの説明を参照してください。

setParameters()が呼ばれたら、setParametersImpl()(165行目~)が動きます(わざわざメソッドを分けたのは、テストも考慮しstaticメソッドにしたかったからです)。

コメント(22行目)にあるように、ここでパース時に設定したparamNamesを頭からループし、引数のMapから取得した値をps.setXXX()で設定しています。

最後のSqlAndParams.javaは、parseSql()からふたつの戻り値を戻すためだけのクラスです。Javaにもout引数欲しい……

SqlAndParams.java

package com.kmaebashi.dbutilimpl;

class SqlAndParams {
    String sql;
    String[] paramNames;

    SqlAndParams(String sql, String[] paramNames) {
        this.sql = sql;
        this.paramNames = paramNames;
    }
}

公開日: 2024/01/06


不具合等ありましたら、掲示板にご連絡願います。

ひとつ上のページに戻る