EF Core SQLiteで複数行を一括更新する方法|条件一致のPlayer1〜4を空にする

EF Core と SQLite で、特定日付に一致する複数行の Player1〜Player4 をまとめて空文字に戻したいのに、1件しか更新されない/ExecuteUpdateAsync が効かない…。この原因の正体と、確実に一括クリアする実装・確認手順を整理します。

目次

やりたいこと:条件に一致する複数行の Player1〜Player4 をまとめてクリア

ゴルフの予約表(TeeSheet)のようなデータで、同じ日付(TeeDate)に紐づく複数枠の「参加者名(Player1〜Player4)」をいったん全消去して空席に戻す、というニーズはよくあります。

ところが EF Core(SQLite)で次のように書こうとして、期待どおりに更新されないケースが発生します。

  • Where(...) で絞り込んだあとに代入したつもりなのに、なぜか最初の1件しか変わらない
  • ExecuteUpdateAsync(...) を試しても更新されない(0件扱いになる/反映されない)

この手のつまずきは、「Where が返すものの正体」と、「EF Core が変更を検知して UPDATE を発行する条件」を押さえると一気に解決します。

まず理解しておきたい:Where が返すのは “複数候補のクエリ(IQueryable)”

DB.Teesheets.Where(...) が返すのは、1件のエンティティではありません。返ってくるのは IQueryable(クエリの設計図)で、SQL に変換されて実行されるのは「列挙したとき(foreach したとき)」「ToListAsync などで具体化したとき」です。

つまり、Where を書いた時点では DB にはまだアクセスしていません。ここが誤解されやすく、意図せず FirstOrDefault() を挟んで「1件だけ」取得してしまったり、追跡されない形で取得してしまったりします。

戻り値正体典型的な使いどころ注意点
IQueryable未実行のクエリWhere/OrderBy/Select で条件を組み立てる列挙するまで SQL は実行されない
エンティティ(1件)具体化されたレコードFirst/Single/Find などで1件取得Where の後に First を付けると “1件だけ” になる
List(複数件)具体化されたレコード集合複数件に対してループで更新件数が多いとメモリ・追跡コストが増える

最小構成の解決コード:一致した行を列挙して更新し、SaveChangesAsync を1回だけ呼ぶ

Where(...) が返すのは複数候補のクエリなので、一致した行を列挙して各レコードを更新し、最後に SaveChangesAsync を1回だけ呼んで確定します。まずはこの形を押さえると迷いが消えます。

// 条件に一致する対象を取得(IQueryable)
var teesheetToClear = DB.Teesheets.Where(e => e.TeeDate == myDateFormatted);

// 一致した全レコードに対して更新
foreach (var item in teesheetToClear)
{
    item.Player1 = "";
    item.Player2 = "";
    item.Player3 = "";
    item.Player4 = "";
}

// 変更をまとめてDBに反映
await DB.SaveChangesAsync();

上のコードでも動きますが、Web アプリでは「同期列挙で DB アクセスが走る」形になりやすいので、次のように ToListAsync で非同期に具体化してから更新するほうが運用で安全です。

// 条件に一致する対象を取得(ここでクエリを実行して具体化)
var teesheetsToClear = await DB.Teesheets
    .Where(e => e.TeeDate == myDateFormatted)
    .ToListAsync();

// 一致した全レコードに対して更新(Change Tracking される)
foreach (var item in teesheetsToClear)
{
    item.Player1 = string.Empty;
    item.Player2 = string.Empty;
    item.Player3 = string.Empty;
    item.Player4 = string.Empty;
}

// 変更をまとめてDBに反映(UPDATE が必要な行だけ発行される)
await DB.SaveChangesAsync();

EF Core は取得したエンティティを既定で追跡するため、プロパティの変更が検知され、SaveChangesAsync のタイミングで UPDATE が発行されます。

「最初の1件しか更新できない」よくある原因と対策

“複数行更新したいのに1件だけ”という症状は、実装のどこかで「1件だけ取得」または「追跡されない形で取得」になっていることが多いです。代表例をまとめます。

症状原因対策
最初の1件しか変わらないWhere の後に FirstOrDefault()/Single() を付けている複数更新なら ToListAsync() で一覧取得しループ更新
SaveChangesAsync しても変わらないAsNoTracking() を付けて取得している更新する処理では追跡ありで取得する(AsNoTracking を外す)
更新したつもりだが0件Select で DTO や匿名型に投影している更新対象は必ずエンティティを取得する(Select を外す)
途中で例外なく終わるが反映されない別コンテキストで取得→別コンテキストで SaveChanges同じ DbContext インスタンスで取得〜保存まで完結させる
一部だけ反映、または上書きされる同一レコードを別処理が更新している(競合)トランザクション/同時更新設計(楽観的同時実行)を検討

AsNoTracking が紛れ込むと “更新されない”

読み取りの高速化で AsNoTracking() を付けていると、EF Core はエンティティを追跡しません。追跡されないエンティティのプロパティを書き換えても、DbContext は変更を知らないため SaveChangesAsync で UPDATE が発行されません。

「一覧表示は AsNoTracking で高速化」「更新するときは追跡ありで取得」と、用途で使い分けるのが安全です。

日付条件が一致しないときのチェックポイント

ExecuteUpdateAsync でも通常の SaveChanges でも、Where 条件が一致しなければ 0 件です。実際にはロジックが正しくても、TeeDate と myDateFormatted の型・形式がズレていて一致していないことが非常によくあります。

よくあるズレ

  • 型のズレ:DB 側は DateTime、アプリ側は文字列(またはその逆)
  • 時刻の混入:DB は 2026-01-06 13:00:00、比較しているのは 2026-01-06(00:00:00)
  • フォーマットの違い:yyyy/MM/dd と yyyy-MM-dd が混在
  • タイムゾーン:UTC 保存とローカル表示が混ざり、日付がズレる
TeeDate の型(おすすめ順)比較の書き方メリット注意点
DateOnlye => e.TeeDate == targetDate日付だけを安全に扱えるEF Core / プロバイダーの対応状況を確認
DateTime(時刻あり)範囲条件:>= start && < end時刻込みでも日付一致を表現できるインデックスと相性が良い書き方を選ぶ
文字列(yyyyMMdd 等)e => e.TeeDate == myDateFormatted単純で分かりやすい形式の統一が崩れると一気に壊れる

DateTime 保存なら “日付での一致” は範囲で書くと堅い

DB に DateTime(時刻つき)で保存しているなら、日付一致は「その日の 00:00 以上、翌日の 00:00 未満」という範囲条件が堅実です。文字列変換よりもズレが起きにくく、SQL の最適化(インデックス利用)もしやすい傾向があります。

var start = targetDate.Date;
var end = start.AddDays(1);

var teesheetsToClear = await DB.Teesheets
    .Where(e => e.TeeDate >= start && e.TeeDate < end)
    .ToListAsync();

foreach (var item in teesheetsToClear)
{
    item.Player1 = "";
    item.Player2 = "";
    item.Player3 = "";
    item.Player4 = "";
}

await DB.SaveChangesAsync();

大量件数なら ExecuteUpdateAsync という “DB側で一括更新” も有効

更新対象が数百〜数万行など「取得して追跡してループ更新」だと重くなる場合、EF Core の ExecuteUpdateAsync のような機能で DB 側で UPDATE を一発で実行する選択肢があります。取得→追跡→差分検知を省けるので、速度とメモリの面で有利になりやすいです。

var affected = await DB.Teesheets
    .Where(e => e.TeeDate == myDateFormatted)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(e => e.Player1, e => "")
        .SetProperty(e => e.Player2, e => "")
        .SetProperty(e => e.Player3, e => "")
        .SetProperty(e => e.Player4, e => ""));

ExecuteUpdateAsync は実行した時点で DB に反映されるため、通常は SaveChangesAsync() を呼ぶ必要はありません(呼んでも更新には関与しません)。また、戻り値として「更新された行数(affected)」が得られるため、本当に条件に一致しているかを数字で確認できます。

ExecuteUpdateAsync が “更新されない” ときに疑うこと

  • 条件が一致していない:日付の型・形式ズレが最優先。affected が 0 なら条件問題の可能性が高い
  • EF Core のバージョン:利用できるかはプロジェクトの EF Core バージョンに依存する
  • プロバイダーの制約:SQLite で翻訳できない式を含むと例外になったり、実行できない形になる
  • 追跡とのズレ:既に追跡中のエンティティがあると、メモリ上の値と DB の値が食い違うことがある

特に最後の「追跡とのズレ」は見落とされがちです。ExecuteUpdateAsync は Change Tracking を通りません。たとえば同じ DbContext が既に対象行を追跡している状態で ExecuteUpdateAsync を実行すると、DB は更新されても、メモリ上の追跡エンティティは古い値のままです。直後に画面へ表示すると「更新されていないように見える」ことがあります。

その場合は、更新後に再取得するか、必要に応じて追跡をクリアします。

await DB.Teesheets
    .Where(e => e.TeeDate == myDateFormatted)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(e => e.Player1, e => "")
        .SetProperty(e => e.Player2, e => "")
        .SetProperty(e => e.Player3, e => "")
        .SetProperty(e => e.Player4, e => ""));

// 追跡中のエンティティがある場合、再取得する前にクリアすると混乱が減る
DB.ChangeTracker.Clear();

アプローチ比較:ループ更新 vs ExecuteUpdateAsync vs 生SQL

プロジェクトの規模や件数、設計方針で最適解は変わります。判断材料を表にしておきます。

方法特徴メリットデメリット/注意
取得してループ更新+SaveChangesChange Tracking を使う分かりやすい/検証しやすい/エンティティイベントや検証と相性が良い件数が多いと遅い・重い(追跡コスト)
ExecuteUpdateAsyncDB 側で一括 UPDATE高速・省メモリ/更新件数が取れる追跡されない/翻訳できない式は使えない/メモリ上の値とズレることがある
生SQL(ExecuteSql…)SQL を直接書く最速になりやすい/SQLite の方言を活かせるSQL の保守が必要/型安全性が落ちる/SQL インジェクション対策が必須

更新が反映されているかを確実に確認する方法

「更新されない」と感じたとき、まずやるべきは SQL と更新件数の可視化です。感覚で追うより圧倒的に早く原因に辿り着きます。

更新対象が本当に存在するかを先に数える

var count = await DB.Teesheets
    .Where(e => e.TeeDate == myDateFormatted)
    .CountAsync();

// count が 0 なら、更新ロジックではなく “条件が合っていない” が本命

発行される SQL をログに出す(開発時)

ASP.NET Core の場合、DbContext の設定でログを出せます。特に日付や文字列の条件は、実際に生成された SQL を見るとズレが一目で分かります。

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlite(connectionString);
    options.EnableSensitiveDataLogging(); // 開発時のみ
    options.LogTo(Console.WriteLine);
});

本番環境では機密情報がログに出るリスクがあるため、EnableSensitiveDataLogging は必ず環境で切り替えましょう。

空文字クリアの設計で迷うポイント:NULL と空文字、どちらにする?

「空にする」を空文字("")で表すか、NULL で表すかは設計次第です。どちらでも動きますが、検索や UI 表示、バリデーションで差が出ます。

  • 空文字で統一:文字列として扱いやすい。UI でそのまま表示しても空。比較が単純
  • NULL を空扱い:値が無いことを DB 的に表現しやすい。インデックスや集計で都合が良いことも

既にテーブルが「NOT NULL + デフォルト空文字」で設計されているなら、空文字でクリアする方針が自然です。NULL 許容にしているなら、検索条件(IS NULL)や UI 側の扱いも含めて統一しましょう。

実運用で強い実装にするための小技

“本当に変える必要がある行” だけを更新する

Player1〜4 が既に空の行まで毎回 UPDATE すると、ログや同期処理がある場合に無駄が増えます。必要なら「どれかが空ではない行だけ」を対象にすると、更新量を減らせます。

var teesheetsToClear = await DB.Teesheets
    .Where(e => e.TeeDate == myDateFormatted)
    .Where(e => e.Player1 != "" || e.Player2 != "" || e.Player3 != "" || e.Player4 != "")
    .ToListAsync();

foreach (var item in teesheetsToClear)
{
    item.Player1 = "";
    item.Player2 = "";
    item.Player3 = "";
    item.Player4 = "";
}

await DB.SaveChangesAsync();

ExecuteUpdateAsync を使う場合も同様に条件を追加できます。更新件数(affected)が減るので、運用上の確認もしやすくなります。

件数が多いときは AutoDetectChanges のコストに注意

追跡ありで大量行をループ更新する場合、ChangeTracker の差分検知が重くなることがあります。極端に件数が多いときは、処理範囲で一時的に自動検知を抑えると改善することがあります(ただし使い方を誤ると更新漏れの原因になります)。

var original = DB.ChangeTracker.AutoDetectChangesEnabled;
DB.ChangeTracker.AutoDetectChangesEnabled = false;

try
{
    var teesheetsToClear = await DB.Teesheets
        .Where(e => e.TeeDate == myDateFormatted)
        .ToListAsync();

    foreach (var item in teesheetsToClear)
    {
        item.Player1 = "";
        item.Player2 = "";
        item.Player3 = "";
        item.Player4 = "";
    }

    // SaveChanges の直前に必要に応じて DetectChanges を呼ぶ
    DB.ChangeTracker.DetectChanges();
    await DB.SaveChangesAsync();
}
finally
{
    DB.ChangeTracker.AutoDetectChangesEnabled = original;
}

「大量更新=常にこれをやるべき」ではありません。まずは ExecuteUpdateAsync などの DB 側一括更新が使えるならそちらを検討し、難しい場合に限り最適化として使うのが安全です。

まとめ:複数行を確実に更新するための要点

  • Where は “複数候補のクエリ(IQueryable)”。First を挟むと1件しか更新できない
  • 複数行更新の基本は「一覧取得→ループでプロパティ変更→SaveChangesAsync を1回」
  • 更新されないときは、日付条件の型・形式ズレと AsNoTracking をまず疑う
  • 件数が多いなら ExecuteUpdateAsync で DB 側更新も有効。ただし追跡されず、表示上ズレることがあるので再取得や追跡クリアを検討
  • 迷ったら “更新対象件数を Count で確認” “発行 SQL をログで確認” が最短ルート

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次