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 の型(おすすめ順) | 比較の書き方 | メリット | 注意点 |
|---|---|---|---|
| DateOnly | e => 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
プロジェクトの規模や件数、設計方針で最適解は変わります。判断材料を表にしておきます。
| 方法 | 特徴 | メリット | デメリット/注意 |
|---|---|---|---|
| 取得してループ更新+SaveChanges | Change Tracking を使う | 分かりやすい/検証しやすい/エンティティイベントや検証と相性が良い | 件数が多いと遅い・重い(追跡コスト) |
| ExecuteUpdateAsync | DB 側で一括 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 をログで確認” が最短ルート

コメント