権限やパフォーマンスを考慮して差分だけ更新するのが良さそう

こんにちは:sunny:

株式会社HRBrainでフロントエンジニア（とバックエンドエンジニア？）をしているみつです！

先日、バックエンドの実装でUpdateの処理を書いた際に、`DeleteAll`してから`InsertAll`の実装でプルリクエストを作ったところ、差分だけUpdateする方が良いとレビューをもらいました。

`DeleteAll`してから`InsertAll`はコードの見通しは良くなるかもしれませんが、権限やパフォーマンスを考慮する場合には、この実装しかないのかぁ。と思い、改めて記事にしながら整理しました。

## 前提
:::note info
- **管理者とユーザーが存在します**
- **管理者はコンテンツに紐づくユーザーを登録する操作を行います**
  - 例）管理者AはユーザーA, ユーザーBをコンテンツAに割り当てることで、各ユーザーはコンテンツAにアクセスすることができるようになります
- **管理者は、ユーザー操作可能な範囲が権限で制限されている場合があります**
  - 例）管理者Aは、一般ユーザーA, B, Cを操作できるが、管理者Bは一般ユーザーB, Cのみしか操作できません
:::

:::note warn
- **データベースの設計上、論理削除を想定した記事となっていますが、物理削除の場合でも検討すべきだと考えています**
:::


## 実現したいこと
- 管理画面から登録操作を行い、特定のユーザーをコンテンツAに割り当てたい
- 特定の管理者が権限足らずで実は見えていないユーザーが消してしまわないようにしたい

## 最初の実装
特定のコンテンツIDに紐づくユーザーレコードをすべて消してから、新たに追加したいユーザーIDsをフロントから受け取り、一括でInsertするようにしました。

しかし、これだと**sqlを実行する時、`companyID`と`ID`に一致するレコードはすべて消えてしまいます**。

```user.go
func (r userRepository) UpdateUsers(
	ctx context.Context,
	companyID uint64,
	contentID domain.contentID,
	userIDs domain.UserIDs,
) error {
	err = r.DeleteAll(ctx, companyID, contentID)
	if err != nil {
		return errorの処理
	}
	err = r.InsertAll(ctx, companyID, contentID, userIDs)
	if err != nil {
		return errorの処理
	}
	return nil
}
```

## レビューをもらった後の実装
フロントから`userIDs`を受け取るところは変わりません。

ただ、usecase層からrepository層に渡す際、新たに`editableUserIDs`というその管理者が操作可能な範囲のユーザーIDを持ってくるようにします。

そうすることで、

- 新規で追加したいユーザー
- 一度削除したが、今回の操作で復元したいユーザー
- 削除したいユーザー

を分類することができ、下記のような良さがあります。

- **管理者が操作可能な範囲でレコードを操作することができる**
- **追加や削除が頻繁に行われるかつ、大量にユーザーがいる場合に毎度InsertAllしなくても差分だけで操作することができる**

```user.go
func (r userRepository) UpdateUsers(
	ctx context.Context,
	companyID uint64,
	contentID domain.contentID,
	newUserIDs domain.UserIDs,
	editableUserIDs domain.UserIDs,
) error {
	currentUserIDs, err := r.getCurrentUserIDs(ctx, companyID, contentID)
	if err != nil {
		return errorの処理
	}
	deletedUserIDs, err := r.getDeletedUserIDs(ctx, companyID, contentID)
	if err != nil {
		return errorの処理
	}

	userIDsToAdd, userIDsToRecover, userIDsToDelete := r.filterUserIDsToUpdate(
		newUserIDs,
		editableUserIDs,
		currentUserIDs,
		deletedUserIDs,
	)

	err = r.CreateUsers(ctx, companyID, contentID, userIDsToAdd)
	if err != nil {
		return errorの処理
	}
	err = r.recoverUsers(ctx, companyID, contentID, userIDsToRecover)
	if err != nil {
		return errorの処理
	}
	err = r.deleteUsers(ctx, companyID, contentID, userIDsToDelete)
	if err != nil {
		return errorの処理
	}
	return nil
}
```

## 具体的な処理の流れ
具体的な例を下記に記載します。

今回、実際行われるのは、

- **`[D]`を新しくInsertする**
- **`[C]`を復元（deletedAtをNULLにする）**

になります。


### 前提条件
- **登場するユーザーは、`[A, B, C, D]`の4人とします**
- **今回の管理者は、`[B]`に対する閲覧権限を持っていないものとします**
- **`[C]`は、今回の操作より前のタイミングで削除された（deletedAtが埋まっている）状態とします**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1841912/2de8e026-6b23-fbac-96b7-7591a5946374.png)

### 現在のデータベースの状態
- `[A, B, ~C~]` (削除済み)

### 処理前
- newUserIDs ▶ フロントから渡ってきたユーザー
  - `[A, C, D]`
  - **`[D]`が新しく登場（添付図参照）**
- editableUserIDs ▶ 今回の管理者が操作可能なユーザー
  - `[A, C, D]`
  - **`[B]`は操作できない（添付図参照）**
- currentUserIDs ▶ 登録されているユーザー
  - `[A, B]`
- deletedUserID ▶  削除されたユーザー
  - `[~C~]`

### 処理後
- memberIDsToAdd ▶ 新しくレコード追加するユーザー
  - `[D]`
- memberIDsToRecover ▶ 復元するユーザー
  - `[C]`
- memberIDsToDelete ▶ 削除するユーザー
  - **本来`[B]`だが、editableUserIDsに含まれないので、管理者は操作ができない**

## まとめ
DeleteAllしてからInsertAllするのは可読性の観点では◎かもしれません。

しかし、権限やパフォーマンスを考慮する時、1つのアプローチとして差分だけを操作する方法があることを知りました。

この実装だけが最適ではないのだろうと思いますが、1つのアプローチとして今後の実装に活かしたいです。

おわり。

## PR
株式会社HRBrainでは、一緒に働く仲間を募集しています！

興味を持っていただいた方はぜひ弊社の採用ページをご確認ください！

HRBrain文化を一緒に作っていきましょう！

https://www.hrbrain.co.jp/recruit/