栽培管理アプリを作ってます⑱:農薬マスタの使い勝手を改善した話

目次

はじめに

前回の記事で農薬マスタに作物名を追加した話を書きました。記事の最後に「未実装」として残していた項目があったので、今回はそれをまとめて対応しました。

  • 農薬の複数まとめて削除
  • グループの編集対応
  • 全登録数の表示

実際に使ってみると気になるところだったので、一気に対応しました。さらに実装後に発覚したCSVインポートのバグ修正も最後に書いています。

1000件制限と向き合う

今回の改善を進めるうえで、まずSupabaseの1000件制限という問題に向き合う必要がありました。Supabaseはデフォルトで1回の問い合わせで取得できるデータが1000件までに制限されています。農薬が増えてくると、この制限が2箇所で問題になります。

問題① 作物ボタンが消える

「すべて」表示のとき、農薬データを全件取得して、その中からJavaScriptで作物名を抽出してボタンを作っていました。でも農薬が1001件目以降はSupabaseが返してくれないので、そこに含まれる作物名はボタンに現れません。

各ボタンにデータが例えば350件ずつ入っていた場合、ほうれんそうで1000件超えるのでボタンが表示されなくなる

解決策:作物名の一覧だけ、別途distinctクエリで取得する

distinctとは「重複を除いて取得する」という命令です。農薬データには同じ作物名が何度も出てきます。

アスパラガス
アスパラガス
アスパラガス
ほうれんそう
ほうれんそう
野菜類

これをdistinctで取得すると重複が消えて:

アスパラガス
ほうれんそう
野菜類

農薬リストとは別に「作物名だけちょうだい」とデータベースに直接聞けば、何件あっても全部返ってきます。これだけで問題①は解決します。

// 変更前(農薬データから抽出・1000件超えると不完全になる)
const cropNames = Array.from(
  new Set(
    (pesticides ?? [])
      .flatMap((p) => p.cult_pesticide_applications.map((a) => a.crop_name))
      .filter((name): name is string => name != null && name !== "")
  )
).sort();

// 変更後(DBに直接問い合わせ・何件でも全部取れる)
const { data: cropNameRows } = await supabase
  .from("cult_pesticide_applications")
  .select("crop_name")
  .eq("owner_id", ownerId)
  .not("crop_name", "is", null)
  .order("crop_name");

const cropNames = Array.from(
  new Set((cropNameRows ?? []).map((r) => r.crop_name as string))
).sort();

問題② 農薬リスト本体が途中で切れる

「すべて」表示のとき農薬が1001件になったら、リストの1001件目以降が表示されません。

問題①の各ボタンを押したときに表示されるリスト

ページネーションも検討したが…

この問題を根本解決するには、ページネーション(100件ずつ表示)が正攻法です。ページネーションとは、大量のデータを「1〜100件目」「101〜200件目」と分割して表示する仕組みです。

ただし実装するには、現在ブラウザ側でやっているフィルタリング(用途・作物・キーワード・五十音での絞り込み)をすべてサーバー側に移す大工事が必要になります。フィルタを変えるたびにサーバーと通信が発生するため、現在の「フィルタ切り替えが瞬時に反映される」という使い心地も失われます。実装コストが高い割にデメリットも大きい。

解決策:「すべて」ボタンを削除して、常に作物別に絞り込む

1作物あたり数百件なら1000件を超えないので、リストが切れません。問題①の解決策だけで「すべて」ボタンを残すこともできますが、「すべて」を押した瞬間に農薬リストが1000件で途切れる問題は残ります。中途半端な状態を避けるために「すべて」ボタンは削除しました。

変更後の動き

画面を開いた瞬間に2種類の問い合わせが走っています。

  • 作物ボタン用(distinctクエリ):アスパラガス・ほうれんそう・さやえんどう の3件だけ返ってくる → ボタンが3つ表示される
  • 農薬リスト用:デフォルト選択されているアスパラガスの農薬を取得済み

「ほうれんそう」をクリックしたら → ほうれんそうの農薬を取得、という流れです。この2つが別々の問い合わせになったことで、農薬が何件に増えても作物ボタンが消えなくなりました。

全登録数をヘッダーに表示する

作物別表示にしたことで新たな問題が出ました。「全部で何件登録されているのか」がどこにも表示されなくなってしまったのです。作物フィルタで絞り込んだ状態が常態になるので、表示件数はその作物の件数だけになります。

フィルタに関係なく「全登録数」を常にヘッダーに表示するようにしました。件数だけを取得する問い合わせを1行追加するだけです。

// 総件数だけ取得(データは1件も取得しないので件数制限なし)
const { count: totalCount } = await supabase
  .from("cult_pesticides")
  .select("*", { count: "exact", head: true })
  .eq("owner_id", ownerId);

データを1件も取得せず件数だけ返ってくるので、何件あっても一瞬です。1000件制限もありません。「全登録数: 782件」と固定表示することで、作物フィルタで絞り込んでいても総数が常に把握できるようになりました。

作物名を選択してまとめて農薬を削除する

前回グループ化機能を実装しましたが、「この作物の農薬をまるごと削除したい」となったとき、1件ずつ削除するしかありませんでした。

「+作物をまとめる」ボタンの隣に「選択削除」ボタンを追加しました。動きはこんな感じです。

  1. 「選択削除」ボタンを押す
  2. 作物名がチェックボックスで一覧表示されるモーダルが開く
  3. 削除したい作物にチェックを入れる
  4. 削除ボタンを押す

注意点として、削除する作物名がグループに含まれている場合は警告を表示するようにしました。

農薬の削除は取り消せないので、グループとの関係をちゃんと確認してから削除できるようにしています。Server Actionsはこんな実装です。

// 選択した作物名に紐づく農薬を削除
export async function deleteCropNamesAction(cropNames: string[]) {
  // 該当crop_nameを持つpesticide_idを取得
  const { data: appRows } = await supabase
    .from("cult_pesticide_applications")
    .select("pesticide_id")
    .in("crop_name", cropNames)
    .eq("owner_id", ownerId);

  const pesticideIds = Array.from(
    new Set((appRows ?? []).map((r) => r.pesticide_id))
  );

  // bulkDeletePesticidesActionと同様のガード処理で削除
  await bulkDeletePesticidesAction(pesticideIds);
}

グループの編集に対応する

前回「編集(準備中)」としていたグループ編集ボタンを実装しました。グループボタンの▼を押すとフッターメニューが出て、「編集」を選ぶと編集モーダルが開きます。編集できる内容はこの2つです。

  • グループ名の変更
  • メンバー(作物名)の追加・削除

実装はupdateCropGroupActionというServer Actionを追加しました。

export async function updateCropGroupAction(
  groupId: string,
  groupName: string,
  cropNames: string[]
) {
  // グループ名を更新
  await supabase
    .from("cult_pesticide_crop_groups")
    .update({ group_name: groupName })
    .eq("id", groupId);

  // メンバーを一旦全削除して再登録
  await supabase
    .from("cult_pesticide_crop_group_members")
    .delete()
    .eq("group_id", groupId);

  await supabase
    .from("cult_pesticide_crop_group_members")
    .insert(cropNames.map((crop_name) => ({ group_id: groupId, crop_name })));
}

メンバーの更新は「全削除して再登録」のシンプルな方式にしました。差分更新より実装がシンプルで、件数も少ないので問題ありません。

実装後に発覚したCSVインポートのバグ

⑱の改善を終えて実際に使い始めたところ、複数のCSVをインポートしていくうちにデータがおかしくなることに気づきました。

症状

アスパラガス・さやえんどう・ほうれんそうの3つのCSVを順番にインポートしていったときのことです。1つ目、2つ目は問題なかったのですが、3つ目を追加したタイミングでフィルタの件数が減りました。

原因は野菜類(アスパラガス・さやえんどう・ほうれんそう全てに共通して含まれている作物)なのはわかったのですが、なぜか一部だけ消える(この例でいうと116件から84件に)
全登録数は増え続けているのに、表示される件数は減っていく。直感的に全く理解できない症状でした。

調査

何が消えたのか調べてると、「スカッシュ」という農薬が消えていたのに気づきました。野菜類にしか適用がない農薬なのに、フィルタから消えていました。SQL Editorで調べてみました。

select p.id, p.name, p.created_at, a.crop_name
from cult_pesticides p
left join cult_pesticide_applications a on a.pesticide_id = p.id
where p.name = 'スカッシュ'
order by p.created_at;

結果を見ると、スカッシュが6件も登録されていて、野菜類の適用詳細を持っているのは1件だけ。残り5件は適用詳細のない本体だけが残っていました。「消えた」のではなく「適用詳細のない本体だけが大量に残っていた」というのが正確なところでした。

原因

インポート処理のコードを確認したところ、農薬マスタのinsert前に既存チェックがありませんでした。CSVをインポートするたびに同名農薬が新規で登録されていたのです。

さらに問題だったのが適用詳細の重複チェックのロジックです。insertした直後に同名農薬のIDを全件取得して、それらの適用詳細を「既存」扱いにする処理があります。重複insertが積み重なるほど「既存」扱いの範囲が広がり、新しくinsertした本体に適用詳細が登録されなくなっていました。

なぜ野菜類の一部だけ消えるのか、詳細な挙動はわかりませんでしたが、調査を進めるうちにそもそも既存チェックがないことが根本原因だとわかったので、まずそこを修正することにしました。

修正

insertの前に同名農薬が既存にあるか確認して、あれば既存IDをそのまま使う実装に変えました。

// 修正前:既存チェックなしでinsert
const { data: pesticideData, error: pesticideError } = await supabase
  .from("cult_pesticides")
  .insert({
    owner_id: ownerId,
    name: pesticide.name,
    ...
  })
  .select()
  .single();

if (pesticideError || !pesticideData) {
  errorCount++;
  continue;
}
// 修正後:既存チェックして既存IDを使う
const { data: existing } = await supabase
  .from("cult_pesticides")
  .select("id")
  .eq("owner_id", ownerId)
  .eq("name", pesticide.name)
  .maybeSingle();

let pesticideId: number;
if (existing) {
  pesticideId = existing.id;
} else {
  const { data: pesticideData, error: pesticideError } = await supabase
    .from("cult_pesticides")
    .insert({
      owner_id: ownerId,
      name: pesticide.name,
      ...
    })
    .select()
    .single();

  if (pesticideError || !pesticideData) {
    errorCount++;
    continue;
  }
  pesticideId = pesticideData.id;
}

検証

DBをクリーンアップして、アスパラ→さやえんどう→ほうれんそうの順で再インポートしながらSQLで1つずつ確認しました。

select p.id, p.name, p.created_at, a.crop_name
from cult_pesticides p
left join cult_pesticide_applications a on a.pesticide_id = p.id
where p.name = 'スカッシュ'
order by p.created_at;
  • アスパラ後:スカッシュ1件、野菜類の適用詳細あり ✅
  • さやえんどう追加後:変化なし ✅
  • ほうれんそう追加後:変化なし ✅

ゴミレコードも0件のまま。3つのCSVを追加しても件数が変わらないことを確認できました。原因の全てを解明できたわけではありませんが、それもおそらくゴミレコードが何か悪さをしていたのだろうと。症状は完全に解消されました。農薬データを一から入れ直して、正しい状態に戻すことができました。

まとめ

  • 全登録数の表示:ヘッダーに固定表示。フィルタに関係なく常に正確な件数を表示
  • 1000件制限の対処:ページネーションではなく作物別表示を採用。作物ボタンの取得もDBへの直接クエリに変更
  • 選択削除:作物名を選んで農薬をまとめて削除。グループに含まれる場合は警告あり
  • グループ編集:グループ名・メンバーの変更に対応
  • CSVインポートのバグ修正:既存チェックなしでinsertしていた根本原因を修正

前回の記事で「未実装」として残していた項目がほぼ片付きました。モバイル版のUIもそこまで変じゃないのでとりあえず良しとしました。
実装後に発覚したバグもあって予定より手間がかかりましたが、データが正しい状態に戻ってすっきりしました。

目次