Spring Bootで検索と一覧表示を行う

# はじめに
Spring Bootアプリケーションで社員名簿を例に一覧表示と検索を行う機能の紹介。
# 環境
#### 開発環境は以下の通り。
Eclipse IDE Version: 2023-12 (4.30.0)
PostgreSQL 16.0,
#### 関連記事
以下の記事で紹介しているログイン機能でログインした場合社員一覧に遷移して検索条件によりデータの抽出が行える。
[Spring Securityでログイン機能を実装する](https://qiita.com/KuboYoshinori/items/07837942161bb5bd4144)
##### サンプルソース
本記事のサンプルソースは「Spring Securityでログイン機能を実装する」と共通で以下からダウンロードできる。解凍する場合はディレクトリからタグ名を除いた「demo_Postgre」にすること。
[Git Hub](https://github.com/bond-baga/demo_Postgre/releases/tag/demo_Postgre)
#### アプリケーションプロパティ
application.propertiesはDBの接続情報を記載する。「Spring Securityでログイン機能を実装する」と同じ。
~~~application.properties
spring.application.name=demo_Postgre
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=root
~~~
#### build.gradle
build.gradleは新規にSpringスターター・プロジェクトを作成する際の依存関係の設定によって自動生成される。
ウィザードの依存関係の設定ではSpring Securityなど必要なライブラリを指定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/5a9f7eff-2f06-8941-59ca-599860435c21.png)
~~~build.gradle
--省略--
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
~~~
#### プログラムソースの構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/2b6ef4fb-7a5d-4d13-9ea7-e5466e1be303.png)

# サンプルプログラム
#### 初期状態
以下のアドレスにアクセスするとログイン画面が表示される。
http://localhost:8080
関連記事で紹介しているログインが完了するとメニューが表示され一覧ボタンをクリックすると以下の一覧表示画面に遷移する。検索条件を入力して検索ボタンをクリックすると検索条件で抽出された一覧が再表示される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/49c40b8d-6236-4c6c-85df-fd5d27a9ce10.png)

###### DB
社員データは以下のmembersテーブルに保持される。
~~~ members.sql
create table public.members (
  code character varying(50) not null
  , name character varying(100)
  , kana character varying(100)
  , statecode integer
  , statuscode integer
  , divisionname character varying(256)
  , remarks character varying(256)
  , primary key (code)
);

~~~
statecodeは社員の状態（通常、休職、退職）のコード。
statuscodeは社員の雇用形態（常勤、非常勤、嘱託、パート）のコード。
状態と雇用形態は以下のnameテーブルに保持している。

~~~ name.sql
create table public.name (
  category integer not null
  , code integer not null
  , name character varying(255) not null
  , primary key (category, code)
);
~~~

nameテーブルの登録内容は以下の通りである。
categoryの1が雇用形態、2が状態になっている。この設定はユーザーの設定によって変更・追加・削除が可能である。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/af69cd7a-8f0c-46c2-b7ad-f8c6e0afc5d2.png)

#### DTO
DBとHTMLでデータのやり取りをするクラス（DTO）が以下のUserForm.javaになる。
~~~ UserForm.java
@Data
public class UserForm implements Serializable{
	private String code;
	private String name;
	private String kana;
	private String password;
	private Integer stateCode;
	private Integer statusCode;
	private String divisionname;

	private String searchname;
	private String searchkana;
	// 状態（複数）
	private List<Integer> stateKeys;

	// 雇用形態
	private Integer statusKey;
	
	// 検索で状態と雇用形態の選択肢を保持する
	private Map<String, Map<Integer, String>> mapitems;
	private String mapstring;状態と雇用形態の表示情報を	
	private Map<String, Object> msg
	
	public UserForm() {
		code = "";
		name = "";
		kana= "";
		password = "";
		stateCode = 0;
		statusCode = 0;
		divisionname = "";
		
		searchname = "";
		searchkana= "";
		stateKeys = new ArrayList<Integer>();
		statusKey = 0;
		mapitems = new HashMap<String, Map<Integer, String>>();
		mapstring = "";
		msg = new HashMap<String, Object>();
	}
}
~~~

このクラスでは社員テーブルのカラムと検索条件を定義している。
検索条件は以下の項目になる。
seachname：氏名
searchkana：カナ
stateKeys：チェックボックス（状態）の選択されたマップのキー（複数選択可能）
statusKey：プルダウン（雇用形態）の選択されたマップのキー（単独選択）
mapitems：一覧の表示時に取得した状態と雇用形態のマップデータ。
mapstring：マップデータをJSON文字列に変換したもの。
msg:エラーメッセージ等を格納する。

:::note info
マップデータをJSON文字列に変換したものを持たす理由
マップデータを一覧のHTMLにhidden属性で設定してもサーバー側では受け取れなかった。
postメソッドでサーバー側に送信できるのは文字列だけ？
:::

#### チェックボックスとプルダウン

list.htmlに渡されたModelのUserForm（DTO）のマップデータで動的にチェックボックスとプルダウンを作成する。
「th:object="${UserForm}"」でModelの中のUserFormを指定する。このタグ内ではUserFormのプロパティは*{プロパティ名}で指定できる。
「th:each="item:*{mapitems.get('state')}"」はUserFormのプロパティを'state'キーで取り出す。その値もまたマップであるからその要素をitem変数で繰り返し取得する。item.keyがキーでitem.valueが値になる。

~~~~list.html
<!DOCTYPE html>
--省略--
<form class="form-center" id="form" method="post" th:object="${UserForm}" th:action="@{/search}">
	// mapitemsではpostされても受け取れない。JSON文字列のmapstringを設定する。
    <input type="hidden" th:field=*{mapstring}>
    <input type="hidden" id="code" th:field=*{code}>
	<div class="slightly-left">
	<h3>検索条件を入力してください。</h3>     
	</div>
	<table class="table-center">
        <tr>
            <td>氏名</td>
            <td><input type="text" th:field=*{searchname}/></td>
        </tr>          
        <tr>
            <td>カナ</td>
            <td><input type="text" th:field=*{searchkana}/></td>
        </tr>

        <tr>
            <td>状態</td>
<!--
    th:eachでマップの要素を繰り返しで取得する。
    #idsはThymeleafのユーティティオブジェクト。エレメント（チェックボックス）とラベルの関連づけを行う。
    ids.next('id_')はマップを先に配置して後のエレメント（チェックボックス）と関連づける場合。
-->   
            <td>
				<th:block th:each="item:*{mapitems.get('state')}">
      			<label th:for="${#ids.next('id_')}" th:text="${item.value}"></label>
      			<input type="checkbox" th:id="${#ids.seq('id_')}" th:field="*{stateKeys}" th:value="${item.key}" th:checked="${#lists.contains({stateKeys}, {item.key})}">
    			</th:block><br>	
            </td>
        </tr> 
        <tr>
          	<td>雇用形態</td>
<!--
    th:field="*{statusKey}"でプルダウンの選択キー。
    th:eachでマップの要素を繰り返しで取得する。
    th:selectedがtrueの場合選択状態になる。
-->
            <td>
    			<select th:field="*{statusKey}">
					<option th:value="0"></option>
					<option th:each="item:*{mapitems.get('status')}" th:value="${item.key}" th:text="${item.value}" th:selected="(${item.key}==*{statusKey})"></option>		
    			</select>
			</td>			
        </tr>

        <tr>
            <td></td>
            <td>
				<button type="button" onclick="searchclick()">検索</button>
				<button type="button" onclick="insertclick()">新規</button> 
            </td>
        </tr>
    </table>	
--省略--
</form>
<script th:inline="javascript">
</script>
</body>
</html>
~~~~

###### チェックボックスとプルダウンの選択
チェックボックの場合は以下のようにstakeKeysの配列がマップの要素のキーを含んでいる場合チェックが付く。
「th:checked="${#lists.contains({stateKeys}, {item.key})}"」

#listsと#idsはThymeleafの[ユーティティオブジェクト](https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf_ja.html#%E5%BC%8F%E3%81%AE%E3%83%A6%E3%83%BC%E3%83%86%E3%82%A3%E3%83%AA%E3%83%86%E3%82%A3%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88)。

プルダウンの場合はマップの要素のキーがstatusKeyと一致している場合選択状態になる。
「th:selected="(${item.key}==*{statusKey})"」

###### エレメント（チェックボックス）の後にラベルを配置する場合
チェックボックスとその説明は関連付けられている。以下のようにチェックボックスの後に関連のラベルを表示することもできる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/6a5098d4-3c8f-47ca-b5b9-e9d3ac21f824.png)

その場合はラベルタグをチェックボックスタグの後に定義して#ids.nextを#ids.prevに変更する。

~~~~list.html
        <tr>
            <td>状態</td>
            <td>
				<th:block th:each="item:*{mapitems.get('state')}">
      			<input type="checkbox" th:id="${#ids.seq('id_')}" th:field="*{stateKeys}" th:value="${item.key}" th:checked="${#lists.contains({stateKeys}, {item.key})}">
      			<label th:for="${#ids.prev('id_')}" th:text="${item.value}"></label>
    			</th:block><br>	
            </td>
        </tr> 
~~~~

#### 一覧データの型
ここでは単独のデータした扱っていないが拡張性（？）を考慮して複数のデータを保持できる仕様にしている。

~~~UserController.java
    //ここでは一つのデータしか取得していないのでList<Map<String, Object>>でよいが
    //.NETのDataSetのように複数のDataTableを保持できるように以下のように定義した。
    //一行のデータを保持しているのがMap<String, Object>になる。
    Map<String, List<Map<String, Object>>> data = new HashMap<>();
    data.put("users", userService.select(form));
    model.addAttribute("data", data);
~~~

#### 一覧表示
メニュー(menu.html)の一覧ボタン押下のpostメソッドまたは一覧(list.html）の検索ボタン押下のpostメソッドで一覧に遷移する。ブラウザの「戻る」ボタンはGetメソッドの履歴を辿るようなのでpostメソッドで直接HTMLを呼び出すのではなくリダイレクトで一覧表示のGetメソッドを呼び出してから一覧HTMLに遷移する。

その際フラッシュスコープにPostメソッドの引数のformデータを格納してGetメソッドではModelAttributeで参照する。フラッシュスコープのデータはHttpSessionに格納され、リダイレクト処理終了後に自動で破棄される。

~~~~UserController.java
@Controller
public class UserController {	
	@GetMapping(value={"/list"})
    public String getList(@ModelAttribute("UserForm") UserForm form, Model model, HttpSession session) {
    	model.addAttribute("title", "一覧"); 
        // フラッシュスコープのUserFormのプロパティにマップデータを付加する。
        //　フラッシュデータをそのままHTMLに渡すだけならUserFormの引数は不要。
        setMapItems(form);
        setSearchKey(form, session);
        // 職員データの取得
        Map<String, List<Map<String, Object>>> data = new HashMap<>();
        // 一覧表示のサービスの戻り値
        data.put("users", userService.select(form));
        model.addAttribute("data", data);
        return "list";
    }

    // 一覧が最初に呼ばれる時に実行されるメソッド。
    // マップデータ（mapitems）をDTOに設定する。
    @PostMapping(value={"/list"})
    public String postList(UserForm form, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session){
    	// 状態（複数）
    	form.setStateKeys(nameService.getStateKeys());
    	// 雇用形態
    	form.setStatusKey(nameService.getStatusKey());
    	//　
		session.setAttribute("StateKeys", form.getStateKeys()); 
		session.setAttribute("StatusKey", form.getStatusKey()); 
		redirectAttributes.addFlashAttribute("UserForm", form); 
        // リダイクレとでGetメソッドを呼び出す。
        return "redirect:/list";
    }
~~~~

:::note info
ブラウザの戻るボタン
入力した項目をサーバーに送信するのはpostメソッドで、一方ブラウザの戻るボタン（またはJavascriptのhistory.back())ではひとつ前のGetメソッドが実行されるようである。ここでは検索ボタン押下で何回でもlist.htmlを更新できるが、戻るボタンでひとつ前の検索条件と検索結果は再現できていない。e-taxなどでも「戻るボタンを押さないでください。」とかであったような・・・。
:::

:::note info
フラッシュスコープ
コントローラー側でpostメソッドで受け取ったフォームをリダイレクトのGetメソッドに渡すのに以下のようにフラッシュスコープを使用している。
redirectAttributes.addFlashAttribute("UserForm", form);

※フラッシュスコープ以外のスコープ
Modelにオブジェクトを格納するのがリクエストスコープ。
HttpSessionに格納され、明示的に破棄するまでHttpSessionに残るのがセッションスコープ。
:::

#### 一覧表示のサービス
selectメソッドで一覧データを返す。検索条件は引数のformのプロパティを参照してSQLを作成している。

~~~~UserService.java
@Service
public class UserService {
	@Autowired
	private JdbcTemplate jdbcTemplate;	
	public List<Map<String, Object>> select(UserForm form) {
		String query = 
		"SELECT"+
		" a.code"+
		",a.name"+
		",a.kana"+
		",b.name statename"+
		",c.name statusname"+
		",a.divisionname"+
		",a.remarks"+
		" FROM members a"+
		" LEFT JOIN name b"+
		" ON a.statecode=b.code AND b.category=2"+
		" LEFT JOIN name c"+
		" ON a.statuscode=c.code AND c.category=1"+
		" WHERE a.name IS NOT null";
		
		if (form.getSearchname() != null && form.getSearchname() != "") {
			query += " AND a.name LIKE '" + form.getSearchname() + "%'";
		}
		if (form.getSearchkana() != null && form.getSearchkana() != "") {
			query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
		}
		
		if (form.getStateKeys().size() != 0) {
			//状態　通常　休職　退職
			query += " AND (";
			for (int i = 0; i < form.getStateKeys().size(); i++) {
				if (i == 0) {
					query += " a.statecode = '" + form.getStateKeys().get(i) + "'";
				}else{
					query += " OR a.statecode = '" + form.getStateKeys().get(i) + "'";				
				}
			}
			query += ")";
		}

		if (form.getStatusKey() != 0) {
			query += " AND a.statuscode = '" + form.getStatusKey() + "'";			
		}
		
		return jdbcTemplate.queryForList(query);
	}	    
}
~~~~

#### 一覧表示のHTML
list.htmlに渡されたModelのdataを一覧表示する。
「th:each="user:*{data.get('users')}"」はModelのマップを'users'キーで取り出す。その値もまたマップであるからその要素をuser変数で繰り返し取得する。

チェックボックスとプルダウンの場合はキー値が不明だったのだが一覧の場合はテーブルのカラム名がキーになっているのでカラム名を指定すると値が取得できる。

~~~~list.html
	<table class="table-center" border="1"> 
	<tr>
    <th>コード</th>
    <th>氏名</th>
    <th>カナ</th>
    <th>状態</th>
    <th>雇用形態</th>
    <th>部署</th>
    <th>備考</th>
    <th>編集</th>
    <th>削除</th>
  	</tr>
  	
	<tr th:each="user : ${data.get('users')}" th:object="${user}">
    <td th:text="*{code}"></td>
    <td th:text="*{name}"></td>
    <td th:text="*{kana}"></td>
    <td th:text="*{statename}"></td>
    <td th:text="*{statusname}"></td>
    <td th:text="*{divisionname}"></td>
    <td th:text="*{remarks}"></td>
    
	</table>
}      
~~~~

#### 一覧表示から新規・編集・削除ページにマップデータを渡す
DTOのmapstringを一覧にhidden属性で設定している。

~~~List.html
<form class="form-center" id="form" method="post" th:object="${UserForm}" th:action="@{/search}">
	<input type="hidden" th:field=*{mapstring}>
	<input type="hidden" id="code" th:field=*{code}>
	
</form>
~~~

一覧で編集などのリンクをクリックした場合はJavaScriptのUpdateclickでサーバー側へのpostメソッドが実行される。サーバー側では上記のmapstringを受け取れる。

~~~List.html
<td>    
	<a href="javascript:void(0);" th:onclick="updateclick([[*{code}]])">編集</a>        
</td>

<script th:inline="javascript">
		
function updateclick(code) {
    let form = document.getElementById("form");
    let input = document.getElementById("code");
    input.value = code;
    form.action="/update";
    form.submit();	
}  
</script>
~~~

一覧HTMLの編集リンクをクリックした場合コントローラーのgetupdate()メソッドが実行される。

setMapItems()メソッドはformのmapstringからmapitemsに変換する処理でmapstringが空文字の場合はサービスを呼び出している。

~~~UserController.java
@Controller
public class UserController {
    // formのmapstringが空の場合はmapitemsをサービスから作成する。
    // formのmapstringが空でない場合はmapstringをマップに変換する。
	@SuppressWarnings("unchecked")
	private void setMapItems(UserForm form) {
    	if (form.getMapstring() == "") {
        	form.getMapitems().put("state", nameService.getState());
        	form.getMapitems().put("status", nameService.getStatus());
        	try {
        		form.setMapstring(mapper.writeValueAsString(form.getMapitems()));
        	} catch (Exception e) {
        		e.printStackTrace();
        	}        
        }else{
        	try {
        		form.setMapitems((Map<String, Map<Integer, String>>) mapper.readValue(form.getMapstring(), Map.class));
        	} catch  (Exception e) {
        		e.printStackTrace();        		
        	}
        }		
	}

	@GetMapping("/update")
    public String getUpdate(@ModelAttribute("UserForm") UserForm form, Model model) {
        model.addAttribute("title", "更新");
        // HTMLのhidden属性のマップ文字列を取得してローカル変数に格納する。
        String tmp = form.getMapstring();
        // HTMLのコードからユーザー情報を取得する。マップ文字列が空の状態。
        form = userService.select(form.getCode());
        // ユーザー情報にマップ文字列を代入。
        form.setMapstring(tmp);
        // formのmapstringからformのmapitemsを作成する。
        setMapItems(form);
        model.addAttribute("UserForm", form);
        return "edit";
    } 
    @PostMapping("/update")
    public String postUpdate(UserForm form, BindingResult result, RedirectAttributes redirectAttributes) {
    	redirectAttributes.addFlashAttribute("UserForm", form); 
    	return "redirect:/update";
    }
}    
~~~

:::note info
ページ間でデータを受け渡す
チェックボックスとプルダウンのデータはそのページごとにサービスから取得してもよいがここでは一覧からhidden属性で取得したデータを新規・編集・削除ページに渡している。
前述のようにマップデータではなくJSON文字列に変換して一覧HTMLに設定している。
:::

#### 状態と雇用形態のデータ取得のサービス
NameService.javaはシステムで使用する状態と雇用形態の読込を行う。
getState()：状態のマップデータを返す。
getStateKeys()：状態の選択されたマップのキーの最小値を返す。デフォルト値。
getStatus()：雇用形態のマップデータを返す。
getStatusKey()：雇用形態の選択されたマップのキーの最小値を返す。デフォルト値。

~~~~NameService.java
@Service
public class NameService {
	@Autowired
	private JdbcTemplate jdbcTemplate;
	
	// 状態（複数）
	private Map<Integer, String> state;
	private List<Integer> stateKeys;

	// 雇用形態
	private Map<Integer, String> status;
	private Integer statusKey = 0;
	
	// 状態（複数）
	public Map<Integer, String> getState() {		
		state = new HashMap<>();	
		String query = "SELECT code, name FROM name WHERE category=2 ORDER BY code";	
		List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
		tmp.forEach((x)->{state.put((Integer) x.get("code"), (String) x.get("name"));});
		return state;
	}	
	
	public List<Integer> getStateKeys() {
		stateKeys = new ArrayList<Integer>();
		String query = "SELECT code, name FROM name WHERE category=2 ORDER BY code";	
		List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
		tmp.forEach((x)->{if (stateKeys.isEmpty()) {stateKeys.add((Integer) x.get("code"));}});
		return stateKeys;
	}

	// 雇用形態
	public Map<Integer, String> getStatus() {		
		status = new HashMap<>();	
		String query = "SELECT code, name FROM name WHERE category=1 ORDER BY code";	
		List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
		tmp.forEach((x)->{status.put((Integer) x.get("code"), (String) x.get("name"));});
		return status;
	}	

	public Integer getStatusKey() {
		String query = "SELECT code, name FROM name WHERE category=1 ORDER BY code";	
		List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
		tmp.forEach((x)->{if (statusKey == 0) {statusKey = (Integer) x.get("code");}});
		return statusKey;
	}
}
~~~~


#### 一覧表示のサービス
selectメソッドで一覧データを返す。検索条件は引数のformのプロパティを参照してSQLを作成している。

~~~~UserService.java
@Service
public class UserService {
	@Autowired
	private JdbcTemplate jdbcTemplate;	
	public List<Map<String, Object>> select(UserForm form) {
		String query = 
		"SELECT"+
		" a.code"+
		",a.name"+
		",a.kana"+
		",b.name statename"+
		",c.name statusname"+
		",a.divisionname"+
		",a.remarks"+
		" FROM members a"+
		" LEFT JOIN name b"+
		" ON a.statecode=b.code AND b.category=2"+
		" LEFT JOIN name c"+
		" ON a.statuscode=c.code AND c.category=1"+
		" WHERE a.name IS NOT null";
		
		if (form.getSearchname() != null && form.getSearchname() != "") {
			query += " AND a.name LIKE '" + form.getSearchname() + "%'";
		}
		if (form.getSearchkana() != null && form.getSearchkana() != "") {
			query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
		}
		
		if (form.getStateKeys().size() != 0) {
			//状態　通常　休職　退職
			query += " AND (";
			for (int i = 0; i < form.getStateKeys().size(); i++) {
				if (i == 0) {
					query += " a.statecode = '" + form.getStateKeys().get(i) + "'";
				}else{
					query += " OR a.statecode = '" + form.getStateKeys().get(i) + "'";				
				}
			}
			query += ")";
		}

		if (form.getStatusKey() != 0) {
			query += " AND a.statuscode = '" + form.getStatusKey() + "'";			
		}
		
		return jdbcTemplate.queryForList(query);
	}	    
}

~~~~

# おわりに
ネットの記事を探りながら検索と一覧表示を作成してみた。戻るボタンの処理など思った通りに機能していない部分があるが機会があれば続編を作成してみたい。