11.詳細・編集・削除
学習目標
@PathVariableで URL のパスから本の id を受け取れるfindByIdとoptional()で 1 冊を取得し、見つからない場合を扱えるth:object・th:fieldで既存値を埋めた編集フォームを作れる- UPDATE・DELETE を
JdbcClientで実行できる - 一覧・詳細・編集・削除で CRUD を完成させ、各操作が 3 層を貫くことを確認できる
データの基本操作は、作成(Create)・取得(Read)・更新(Update)・削除(Delete)の 4 つで、頭文字をとって CRUD と呼びます。JdbcClient までに、登録(Create)と一覧・検索による取得(Read)を実装しました。本章で、1 冊の詳細表示(Read)・編集(Update)・削除(Delete)を加え、CRUD をそろえます。
1. 本の詳細
Section titled “1. 本の詳細”一覧から 1 冊を選び、その詳細を表示する画面を作ります。
1-1. id による本の識別
Section titled “1-1. id による本の識別”一覧は全件を並べるだけで、個々の本を区別する必要がありませんでした。1 冊を取り出すには、どの本かを識別する値が要ります。それが、books テーブルの主キー id です。取得した本を id で扱えるよう、Book に id を加えます。
package com.example.bookstore;
public class Book { private final Long id; private final String title; private final int price; private final int stock;
public Book(Long id, String title, int price, int stock) { this.id = id; this.title = title; this.price = price; this.stock = stock; }
public Long getId() { return id; }
public String getTitle() { return title; }
public int getPrice() { return price; }
public int getStock() { return stock; }}id の型は Long です。
一覧から詳細へ移れるよう、findAll の SELECT に id を加え、各行に詳細へのリンクを張ります。
public List<Book> findAll() { return jdbcClient.sql("SELECT id, title, price, stock FROM books") .query(Book.class) .list();}books.html の一覧で、タイトルを詳細へのリンクにします。
<tr th:each="book : ${books}" th:class="${book.stock == 0} ? 'sold-out' : ''"> <td><a th:href="@{/books/{id}(id=${book.id})}" th:text="${book.title}">こころ</a></td> <td><span th:text="${book.price}">473</span>円</td> <td th:text="${book.stock}">5</td></tr>th:href はリンク先を設定する属性です。@{...} は URL 式 で、/books/{id} の {id} に (id=${book.id}) で本の id を埋めます。id が 1 の本なら、リンク先は /books/1 になります。
1-2. @PathVariable
Section titled “1-2. @PathVariable”詳細画面の URL は /books/1 のように、パスに id を含めます。パスの一部を受け取るには @PathVariable を使います。
@GetMapping("/books/{id}")public String メソッド名(@PathVariable Long id, ...) { ... }@GetMapping("/books/{id}") の {id} が変わる部分で、同じ名前の引数 @PathVariable Long id がその値を受け取ります。/books/1 なら id に 1 が入ります。
クエリパラメーター を受け取る @RequestParam との違いは、値の位置です。
| アノテーション | 値の位置 | 例 |
|---|---|---|
@PathVariable | パスの一部 | /books/1 |
@RequestParam | クエリ(? 以降) | /books?keyword=猫 |
一覧を絞る検索条件はクエリ、特定の 1 件を指す id はパス、と使い分けます。
1-3. findById と optional
Section titled “1-3. findById と optional”BookRepository に、id で 1 冊を取り出す findById を加えます。
public Optional<Book> findById(Long id) { return jdbcClient.sql("SELECT id, title, price, stock FROM books WHERE id = :id") .param("id", id) .query(Book.class) .optional();}1 つの値の取得 で使った single() は、結果がちょうど 1 件のときのものでした。詳細では、指定した id の本があるとは限りません。0 件か 1 件かを受け取るのが optional() で、戻り値は Optional<Book> です。本があれば中身を持ち、なければ空になります。java.util.Optional の import が加わります。
BookService は findById を BookRepository に委譲します。
public Optional<Book> findById(Long id) { return bookRepository.findById(id);}BookController で、id を @PathVariable で受け取り、本を取得します。
@GetMapping("/books/{id}")public String detail(@PathVariable Long id, Model model) { Optional<Book> bookOpt = bookService.findById(id); if (bookOpt.isEmpty()) { return "redirect:/books"; } model.addAttribute("book", bookOpt.get()); return "books/detail";}bookOpt.isEmpty() は Optional が空か(本が見つからなかったか)を判定します。空なら一覧へリダイレクトし、本があれば bookOpt.get() で Optional から本を取り出して Model に渡します。
詳細画面を表示する books/detail.html です。
<!DOCTYPE html><html lang="ja" xmlns:th="http://www.thymeleaf.org"><head th:replace="~{fragments/layout :: head('本の詳細')}"></head><body> <h1 th:text="${book.title}">こころ</h1> <p>価格: <span th:text="${book.price}">473</span>円</p> <p>在庫: <span th:text="${book.stock}">5</span></p> <a th:href="@{/books/{id}/edit(id=${book.id})}">編集</a></body></html>一覧のタイトルをクリックすると、その本の詳細が表示されます。
2. 本の編集
Section titled “2. 本の編集”詳細画面の「編集」から、本の内容を更新できるようにします。
2-1. 編集フォーム
Section titled “2-1. 編集フォーム”編集は、既存の値をフォームに表示してから修正します。本の登録 のフォームは空の入力欄でしたが、編集では各欄に現在の値を埋めておきます。
入力欄に値を埋めるには、フォームを対象のオブジェクトに結びつけます。th:object がフォーム全体を 1 つのオブジェクトに、th:field が各入力欄をそのフィールドに結びつけます。
<form th:object="${オブジェクト}"> <input th:field="*{フィールド名}"></form>th:field="*{title}" は、th:object のオブジェクトの title を入力欄に結びつけ、name・id・value を自動で生成します。value には現在の値が入るため、既存の値が表示されます。
たとえば、title に "こころ" を入れた BookForm を渡すと、th:field="*{title}" の入力欄は次の HTML を生成します。
<input type="text" id="title" name="title" value="こころ">value="こころ" が入るため、フォームを開くと入力欄に既存のタイトルが表示されます。
編集フォームを表示する処理を BookController に加えます。先ほどは値を直接入れましたが、実際にはデータベースから本を取得し、その値を入れた BookForm をテンプレートに渡します。
@GetMapping("/books/{id}/edit")public String editForm(@PathVariable Long id, Model model) { Optional<Book> bookOpt = bookService.findById(id); if (bookOpt.isEmpty()) { return "redirect:/books"; } Book book = bookOpt.get();
BookForm form = new BookForm(); form.setTitle(book.getTitle()); form.setPrice(book.getPrice()); model.addAttribute("bookForm", form); model.addAttribute("bookId", id); return "books/edit";}取得した本の title と price を BookForm に入れ、bookForm という名前で渡します。id は入力欄ではなく送信先の URL に使うため、BookForm には入れず、bookId として別に渡します。
<!DOCTYPE html><html lang="ja" xmlns:th="http://www.thymeleaf.org"><head th:replace="~{fragments/layout :: head('本の編集')}"></head><body> <h1>本の編集</h1> <form th:action="@{/books/{id}(id=${bookId})}" method="post" th:object="${bookForm}"> <label for="title">タイトル:</label> <input type="text" th:field="*{title}"> <label for="price">価格:</label> <input type="number" th:field="*{price}"> <button type="submit">更新</button> </form></body></html>th:action="@{/books/{id}(id=${bookId})}" で、送信先を /books/1 のように本の id を含む URL にします。フォームを開くと、各欄に既存のタイトルと価格が表示されます。
2-2. 更新の実行
Section titled “2-2. 更新の実行”フォームの送信先は /books/{id} への POST です。id を @PathVariable、入力値を @ModelAttribute で受け取ります。
BookRepository に、UPDATE で本を更新する update を加えます。
public void update(Long id, String title, int price) { jdbcClient.sql("UPDATE books SET title = :title, price = :price WHERE id = :id") .param("title", title) .param("price", price) .param("id", id) .update();}WHERE id = :id で、更新する 1 行を id で指定します。在庫は編集しないので、更新するのは title と price です。
BookService に update を加えます。
public void update(Long id, BookForm form) { bookRepository.update(id, form.getTitle(), form.getPrice());}BookController で、/books/{id} への POST を受け取ります。
@PostMapping("/books/{id}")public String update(@PathVariable Long id, @ModelAttribute BookForm form) { bookService.update(id, form); return "redirect:/books";}更新後は リダイレクト で一覧へ戻します。/books/1/edit でタイトルや価格を修正して更新すると、一覧にその変更が反映されます。
3. 本の削除
Section titled “3. 本の削除”3-1. 削除ボタン
Section titled “3-1. 削除ボタン”削除は、データを消す副作用のある操作です。フォーム送信 で取得には GET、データの送信には POST を使いました。削除も POST で送ります。リンク(GET)で削除できると、ページを開いただけで本が消えるおそれがあるためです。
詳細画面に、ボタンだけを送る削除フォームを置きます。
<form th:action="@{/books/{id}/delete(id=${book.id})}" method="post"> <button type="submit">削除</button></form>/books/{id}/delete への POST で、その本を削除します。
3-2. deleteById
Section titled “3-2. deleteById”BookRepository に、DELETE で本を消す deleteById を加えます。
public void deleteById(Long id) { jdbcClient.sql("DELETE FROM books WHERE id = :id") .param("id", id) .update();}DELETE FROM books WHERE id = :id で、id で指定した 1 行を削除します。INSERT・UPDATE と同じく、DELETE も update() で実行します。
BookService に delete を加えます。
public void delete(Long id) { bookRepository.deleteById(id);}BookController で、/books/{id}/delete への POST を受け取ります。
@PostMapping("/books/{id}/delete")public String delete(@PathVariable Long id) { bookService.delete(id); return "redirect:/books";}削除後は一覧へリダイレクトします。詳細画面で削除ボタンを押すと、その本が消え、一覧に戻ります。
4. CRUD と 3 層
Section titled “4. CRUD と 3 層”これで、本の作成・取得・更新・削除がそろいました。
| 操作 | HTTP メソッド | パス | Repository |
|---|---|---|---|
| 一覧(Read) | GET | /books | findAll |
| 詳細(Read) | GET | /books/{id} | findById |
| 登録(Create) | POST | /books | save |
| 編集フォーム | GET | /books/{id}/edit | findById |
| 更新(Update) | POST | /books/{id} | update |
| 削除(Delete) | POST | /books/{id}/delete | deleteById |
どの操作も、BookController(ルーティング)→ BookService(窓口)→ BookRepository(SQL)の レイヤードアーキテクチャ を貫きます。操作を加えても、各層に同じ役割でメソッドが増えるだけで、構成は変わりません。