コードの「なぜ?」Q&A 第5章 CategoryDAO.java

Q. getGeneratedKeys()で、なぜrs.next()が必要なのですか?

INSERT後に自動採番されたIDを取得するコードを、ResultSetのカーソル操作まで順に確認します。

Javaでデータベースアプリケーションを学び始めたころ、私自身が「なぜ、こんな書き方をするのだろう?」と悩んだコードの一つが、JDBCのgetGeneratedKeys()でした。

INSERTそのものは比較的分かりやすいと思います。

pstmt.executeUpdate();

これでレコードを追加できます。

ところが、自動採番される主キーを取得しようとすると、急に次のようなコードが登場します。

PreparedStatement pstmt = conn.prepareStatement(
        INSERT_SQL,
        Statement.RETURN_GENERATED_KEYS);

さらに、INSERTを実行した後で、

ResultSet rs = pstmt.getGeneratedKeys();

そして、

if (rs.next()) {
    return rs.getInt(1);
}

と続きます。

初めて見たとき、この数行のコードだけでいくつもの疑問を持ちました。

  • INSERTなのに、なぜResultSetが出てくるのか
  • IDを1個取得したいだけなのに、なぜrs.next()が必要なのか
  • getInt(1)1は何を意味するのか
  • PreparedStatementを使っているのに、なぜStatement.RETURN_GENERATED_KEYSなのか
  • そもそもgetGeneratedKeys()は、どこからIDを取得しているのか

この記事では、こうした疑問を一つずつ整理しながら、

JDBCでINSERTした直後に、データベースが自動採番したIDをJava側で取得する仕組み

を見ていきます。


今回使うテーブル

例として、PostgreSQLに次のcategoriesテーブルがあるものとします。

CREATE TABLE categories (
    category_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    category_name VARCHAR(10) NOT NULL
);

category_idGENERATED ALWAYS AS IDENTITYになっているため、レコードを追加するとPostgreSQLがIDを自動的に割り当てます。

例えば、分類名として「交際費」を追加するとします。

SQLは次のようになります。

INSERT INTO categories (category_name)
VALUES (?);

category_idはSQLに指定していません。

PostgreSQL側で自動採番されるからです。

INSERTはできた。でもIDは何番?

Java側では、例えば次のように実行できます。

private static final String INSERT_SQL =
        "INSERT INTO categories (category_name) VALUES (?)";
PreparedStatement pstmt =
        conn.prepareStatement(INSERT_SQL);

pstmt.setString(1, "交際費");
pstmt.executeUpdate();

これで登録自体はできます。

しかし、ここで一つ問題があります。

PostgreSQLでは、

category_id   = 自動採番された値
category_name = 交際費

というレコードが作られていますが、Java側はまだ、

今追加されたレコードのcategory_idが何番なのか

を知りません。

例えば登録後に、

分類が追加されました。(新しいID: 9)

のように表示したい場合、このIDをJava側へ返してもらう必要があります。

処理の流れで考えると、こうなります。

Java
  │
  │ INSERT「交際費」
  ↓
PostgreSQL
  │
  │ category_idを自動採番
  ↓
レコードを登録
  │
  │ 自動生成したIDを返す
  ↓
Java

この最後の、

「データベースが生成したIDをJava側で受け取る」

ために使うのがgetGeneratedKeys()です。

解決方法の全体像

細かなコードを見る前に、処理全体を把握しておきましょう。

必要なのは次の4段階です。

① 自動生成されたキーを取得したい、と指定する
        ↓
② INSERTを実行する
        ↓
③ 生成されたキーを取得する
        ↓
④ ResultSetからIDを読み取る

JDBCのコードに対応させると、

Statement.RETURN_GENERATED_KEYS
        ↓
executeUpdate()
        ↓
getGeneratedKeys()
        ↓
rs.next()
        ↓
rs.getInt(1)

となります。

完成形を先に見ると、次のようになります。

public int insertCategory(CategoryDTO category)
        throws SQLException {

    try (Connection conn =
                 DatabaseConnection.getConnection();
         PreparedStatement pstmt =
                 conn.prepareStatement(
                         INSERT_SQL,
                         Statement.RETURN_GENERATED_KEYS)) {

        pstmt.setString(
                1, category.getCategoryName());

        pstmt.executeUpdate();

        try (ResultSet rs =
                     pstmt.getGeneratedKeys()) {

            if (rs.next()) {
                return rs.getInt(1);
            } else {
                throw new SQLException(
                        "分類の登録は実行されましたが、"
                        + "自動生成されたIDを取得できませんでした。");
            }
        }
    }
}

初めて見ると少し複雑です。

しかし、一つずつ分解すると、それぞれの役割はかなり明確です。

1. RETURN_GENERATED_KEYSを指定する

最初のポイントはここです。

PreparedStatement pstmt =
        conn.prepareStatement(
                INSERT_SQL,
                Statement.RETURN_GENERATED_KEYS);

通常なら、

conn.prepareStatement(INSERT_SQL);

だけでもINSERTは実行できます。

今回は第2引数として、

Statement.RETURN_GENERATED_KEYS

を指定しています。

これは、

このSQLによって自動生成されたキーを、あとで取得したい

という指定です。

つまり、getGeneratedKeys()をあとで呼び出すための準備を、PreparedStatementを作る段階で行っています。

PreparedStatementなのに、なぜStatement.RETURN_GENERATED_KEYS

ここも、私が最初に少し引っかかったところです。

使っているオブジェクトは、

PreparedStatement

です。

それなのに指定する定数は、

Statement.RETURN_GENERATED_KEYS

となっています。

理由は単純で、RETURN_GENERATED_KEYSという定数がjava.sql.Statementに定義されているからです。

そのため、次のimportが必要になります。

import java.sql.Statement;

EclipseなどのIDEで自動importする場合は、同名の別クラスではなく、

java.sql.Statement

であることを確認してください。

ここでは、

PreparedStatementを使っている
        ↓
でもRETURN_GENERATED_KEYSという定数は
Statementに定義されている
        ↓
Statement.RETURN_GENERATED_KEYS

と考えればよいでしょう。

2. executeUpdate()でINSERTする

次は通常のINSERTと同じです。

pstmt.setString(
        1, category.getCategoryName());

pstmt.executeUpdate();

ここでPostgreSQLへINSERT文が送られます。

例えば、

categoryName = "交際費"

なら、PostgreSQL側で新しいレコードが作られ、その際にcategory_idも自動生成されます。

ここで注意したいのは、

getGeneratedKeys()がINSERTを行うわけではない

ということです。

順序は、

まずINSERTする
    ↓
そのINSERTで生成されたキーを取得する

です。

したがって、

pstmt.executeUpdate();

getGeneratedKeys()を呼び出します。

executeUpdate()の戻り値がIDなのでは?

ここも混同しやすいところです。

executeUpdate()には戻り値があります。

例えば、

int rows = pstmt.executeUpdate();

と書けます。

しかし、このrowsは自動採番されたIDではありません。

INSERTUPDATEDELETEなどによって処理された行数を表します。

例えば1件のレコードを追加すれば、通常は、

rows = 1

となります。

しかし、

ID = 9

という意味ではありません。

したがって、

int newId = pstmt.executeUpdate();

として自動採番IDを取得することはできません。

生成されたキーは、別に取得する必要があります。

そこで登場するのが、

pstmt.getGeneratedKeys()

です。

3. getGeneratedKeys()で生成されたキーを取得する

INSERT実行後に、次のコードを書きます。

ResultSet rs =
        pstmt.getGeneratedKeys();

実際には、ResultSetもクローズする必要があるため、try-with-resourcesを使って、

try (ResultSet rs =
             pstmt.getGeneratedKeys()) {

    // 自動生成されたIDを取得
}

とするのが分かりやすいでしょう。

ここで、初学者にとって少し意外なものが登場します。

ResultSetです。

INSERTなのに、なぜResultSetが出てくるのか?

JDBCを学び始めると、まずSELECTでResultSetを使うことが多いと思います。

ResultSet rs =
        pstmt.executeQuery();

そして、

while (rs.next()) {
    ...
}

と書きます。

そのため、

ResultSetはSELECTの検索結果を扱うもの

という印象を持ちやすいかもしれません。

しかし、ここで扱っているResultSetは、categoriesテーブルをSELECTした検索結果ではありません。

pstmt.getGeneratedKeys()

によって得られる、

「今回のSQL実行によって生成されたキーの取得結果」

です。

イメージとしては、

INSERT実行
   ↓
PostgreSQLがIDを生成
   ↓
getGeneratedKeys()
   ↓
生成されたキーのResultSet

です。

今回、仮にcategory_id = 9が生成されたとすれば、概念的には、

生成されたキーの結果

1列目
-----
9

のような結果を受け取っていると考えると分かりやすくなります。

4. なぜrs.next()が必要なのか?

次に出てくるのが、

if (rs.next()) {
    return rs.getInt(1);
}

です。

ここも、私自身が最初に疑問を持ったところです。

欲しいものはID一つだけです。

それなのに、なぜ、

rs.next()

するのでしょうか。

理由は、getGeneratedKeys()が返すものもResultSetだからです。

ResultSetを取得した直後のカーソルは、最初の行を指しているわけではありません

取得直後

カーソル
   ↓
[最初の行より前]

1行目   9

そこで、

rs.next();

を実行します。

すると、

rs.next()後

カーソル
   ↓
1行目   9

となります。

この状態になって初めて、

rs.getInt(1)

で値を取得できます。

「INSERTした行の位置」へ移動しているわけではない

ここは特に重要です。

rs.next()と聞くと、

categoriesテーブルの中で、今INSERTしたレコードの行まで移動しているのか?

と思うかもしれません。

そうではありません。

今回操作しているのはcategoriesテーブルそのものではなく、

pstmt.getGeneratedKeys()

が返した生成キー用のResultSetです。

つまり、

categoriesテーブル

1 給与
2 配当金
3 雑収入
...
9 交際費

の中をrs.next()で移動しているわけではありません。

そうではなく、

getGeneratedKeys()の結果

1行目   9

という別の結果に対して、

rs.next();

しているのです。

今回のように1件のレコードをINSERTし、その1件について生成されたIDを取得する場合は、

getGeneratedKeys()
        ↓
生成されたキーの結果
        ↓
rs.next()
        ↓
1行目
        ↓
rs.getInt(1)

という流れになります。

この違いが分かると、rs.next()が必要な理由も理解しやすくなります。

rs.getInt(1)1は何?

続いて、

return rs.getInt(1);

です。

この1は、自動採番されたIDの値ではありません。

ResultSetの1列目

という意味です。

例えば取得結果が、

1列目
-----
9

なら、

rs.getInt(1)

によって、

9

を取得します。

したがって、

if (rs.next()) {
    return rs.getInt(1);
}

は、

生成されたキーの結果に1行目があるか確認する
        ↓
あれば、その1列目をintとして取得する
        ↓
そのIDをreturnする

という処理です。

なぜifなのか? whileではないのか?

SELECTで複数件を取得する場合は、

while (rs.next()) {
    ...
}

と書くことが多いでしょう。

しかし今回は、1件の分類を登録し、その1件について生成されたIDを一つ取得したいだけです。

そのため、

if (rs.next()) {
    return rs.getInt(1);
}

で十分です。

そして、もし生成されたキーを取得できなかった場合は、

else {
    throw new SQLException(
            "分類の登録は実行されましたが、"
            + "自動生成されたIDを取得できませんでした。");
}

として異常な状態を呼び出し元へ伝えています。

完成コードをもう一度見る

ここまで理解したところで、最初のコードをもう一度見てみます。

public int insertCategory(CategoryDTO category)
        throws SQLException {

    try (Connection conn =
                 DatabaseConnection.getConnection();
         PreparedStatement pstmt =
                 conn.prepareStatement(
                         INSERT_SQL,
                         Statement.RETURN_GENERATED_KEYS)) {

        pstmt.setString(
                1, category.getCategoryName());

        pstmt.executeUpdate();

        try (ResultSet rs =
                     pstmt.getGeneratedKeys()) {

            if (rs.next()) {
                return rs.getInt(1);
            } else {
                throw new SQLException(
                        "分類の登録は実行されましたが、"
                        + "自動生成されたIDを取得できませんでした。");
            }
        }
    }
}

それぞれの役割を書き加えると、次のようになります。

Statement.RETURN_GENERATED_KEYS
    ↓
「生成されたキーを後で取得したい」と指定

executeUpdate()
    ↓
INSERTを実行

getGeneratedKeys()
    ↓
INSERTによって生成されたキーを取得

rs.next()
    ↓
生成キーの結果の1行目へ移動

rs.getInt(1)
    ↓
1列目のIDをintとして取得

最初は少し複雑に見えたコードも、処理の流れが分かれば、それぞれの行が必要な理由を説明できるようになります。

呼び出し側では、取得したIDを使える

insertCategory()は、取得したIDをintで返しています。

そのため呼び出し側では、

CategoryDTO newCategory =
        new CategoryDTO(0, name);

int newId =
        categoryDAO.insertCategory(newCategory);

System.out.println(
        "分類が追加されました。(新しいID: "
        + newId + ")");

のように使えます。

登録前は、

Java

categoryName = "交際費"
categoryId   = まだ分からない

でした。

それが、

Java
  │
  │ INSERT
  ↓
PostgreSQL
  │
  │ IDを自動生成
  ↓
getGeneratedKeys()
  │
  ↓
Java

newId = 自動生成されたID

となります。

これで冒頭の、

INSERTは成功した。でも、今追加されたレコードのIDは何番なのか?

という問題が解決しました。

まとめ

JDBCでINSERT後の自動採番IDを取得する基本的な流れは、次のようになります。

① 自動生成キーを取得することを指定する

Statement.RETURN_GENERATED_KEYS

        ↓

② INSERTする

executeUpdate()

        ↓

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

getGeneratedKeys()

        ↓

④ ResultSetの1行目へ移動する

rs.next()

        ↓

⑤ 1列目のIDを取得する

rs.getInt(1)

特に、最初は次の点が分かりにくいかもしれません。

  • executeUpdate()の戻り値は生成されたIDではなく、処理された行数
  • getGeneratedKeys()が返すのはResultSet
  • そのResultSetはテーブルそのものではなく、生成されたキーの取得結果
  • rs.next()は、INSERTしたテーブル上の行へ移動しているのではなく、生成キーのResultSetの最初の行へ移動している
  • getInt(1)1は、ResultSetの1列目という意味

私自身、JDBCを学び始めたころは、これらが一つの処理としてつながって見えていませんでした。

getGeneratedKeys()というメソッド名だけを覚えるよりも、

「INSERT時にデータベースが生成したキーを、Java側へ受け取る一連の処理」

として理解すると、かなり分かりやすくなると思います。