フロントエンドでのデータ一意性のバリデーション(重複チェック)

CRUD 系ページのフロントエンドで一意性検証を行う方法を考えてみました。

軽くググった感じ似たような記事は見つかりませんでした。というか、関係のない記事がたくさんヒットしてしまって、調べるのがめんどうになりました。こういう記事があるよとか、自分はこういうやり方をしているぞとかあれば、ぜひコメントしてもらえると嬉しいです。

## 目的

(保存時ではなく) ユーザーが変更作業をしている最中に、一意性の違反を警告することで、入力上の誤りに気付いてもらいやすくします。

なお、変更作業中に他のユーザーが保存処理を行うことでフロントエンドの状態とデータベースの状態にギャップが生じる問題は、ひとまず脇に置くことにします。

## ページのイメージ

データグリッドがあって、追加や変更、削除などの操作ができ、保存ボタンを押すと一括で更新される、という機能を想定します。商品名の重複は NG とします。

| 商品名                | 価格   | 削除 | 備考       |
|:-------------------|:--------|:----|:---------|
| やくそう(小)          | 100     | [ ] | (変更なし) |
| ~~やくそう(中)~~      | ~~200~~ | [✔] | (削除される行) |
| **やくそう(大)**     | 400      | [ ] | (変更される行) |
| **やくそう(特大)**    | **800** | [ ] | (追加される行) |
|                   |           |    | (空欄)       |

## 方法1: トランザクションで処理する

まず思いつくのは、更新内容が変化するたびにトランザクション内で保存処理を試行する方法です。

適当なタイミングで更新内容をサーバーに送ります。サーバー側でトランザクションを張りつつ保存処理を実行し、UNIQUE 制約違反のエラーが発生するかを確かめ、フロントエンドに報告してもらいます。トランザクションを張っているので、コミットせずにロールバックすれば、変更はキャンセルされるので大丈夫です。

この方法はデータベースへの負荷が気になります (どのくらいの負荷なのか、詳しくは分かりませんが……)。また、NoSQL なデータベースにはトランザクション機能がないものもあります。

- 利点: 実装が軽い
- 欠点: データベースに負荷がかかる。トランザクション機能がないと使えない。

## 方法2: インメモリで処理する

もう1つ、素朴な方法があります。あらかじめテーブル内のデータをすべて取得しておき、保存処理をメモリ上でシミュレートしてみて、データに重複が発生するか調べる方法です。

データ件数が多くなると全件取得のコストが高すぎて、現実的ではなくなります。

- 利点: 素朴
- 欠点: データ件数が少なくないと性能的に厳しい

## 方法3: 本題

そういうわけで本題です。データベースへのアクセスをほどほどに抑える方法を考えます。

保存後にデータが重複する可能性を以下の4つに分類します。

- (I) 重複データのうち、2件以上が挿入されたものであるケース
- (U) 重複データのうち、2件以上が更新されたものであるケース
- (B) 重複データに、挿入されたものと、更新されたものの **両方** が含まれるケース
- (E) 重複データに、挿入されたものと、更新されたものの **どちらか一方だけ** が含まれるケース

(I), (U), (B) のケースは、保存されたデータ同士の重複なので、変更内容を調べれば検出できます。つまりフロントエンド側だけで行なえます。

(E) のケースは、保存時に挿入または更新されるデータと重複するものをデータベースから検索し、ヒットすれば重複するとみなせます。ただし、同じレコード同士の重複をヒットさせないために、フロントエンド側で持っているレコードは除外します。

計算量は更新内容のオーダーで抑えられます。手作業による1回の更新内容は十分に小さいと考えられるため、たいていは問題ないはず。

- 利点: データ件数によらず高速
- 欠点: 処理が複雑

## コードのイメージ

```ts
type SaveKind = "INSERT" | "UPDATE" | "DELETE"

type ItemId = string

interface Item {
    itemId: ItemId
    name: string
    price: number
    save?: SaveKind
}

// excludedItemIds に指定されていないアイテムのうち、
// 名前が names のどれかに一致するものを検索する。
const findItemsByNames = (
    names: string[],
    excludedItemIds: ItemId[],
    connection: DatabaseConnection
): Item[] => {
    // 略
}

const validateItemNameUniqueness = (
    items: Item[],
    connection: DatabaseConnection
): boolean => {
    // 変更内容上の重複を検査する。
    const nameFrequency = new Map<string, number>()

    for (const item of items) {
        if (item.save === "INSERT" || item.save === "UPDATE") {
            // nameFrequency[item.name] += 1 といいたい
            nameFrequency.set(item.name, (nameFrequency.get(item.name) ?? 0) + 1)
        }
    }

    for (const [name, frequency] of nameFrequency.entries()) {
        if (frequency >= 2) {
            // name が重複している。
            return false
        }
    }

    // 未取得データとの重複を検査する。
    const nonDeletedNames =
        items
        .filter(item => item.save !== "DELETE")
        .map(item => item.name)
    const excludedItemIds =
        items.map(item => item.itemId)
    const duplicatedItems =
        findItemsByNames(nonDeletedNames, excludedItemIds, connection)

    if (duplicatedItems.length >= 1) {
        // duplicatedItems[0].name が重複している。
        return false
    }

    return true
}
```

(コードはイメージです。デバッグしてません。)

## その他

- インメモリでの重複検査には集合系のコレクション (`Set`/`Map`) を使いましょう。2重ループは遅いし、冗長です。
- 入力検証は整合性の確保にならないので、データベース側の UNIQUE 制約は別途必要です。