Entity Framework でクエリが実行されるタイミングを理解する


はじめに

C# の OR マッパー Entity Framework Core では LINQ to Entities を使用してオブジェクトを操作するようにデータストア(例えばリレーショナルデータベース)にアクセスすることができます。

IQueryable<Book> booksQuery = dbContext.Books
  .Where(book => book.AuthorName == "Robert C.Martin")
  .OrderBy(book => book.PublishYear);

クエリ変数の型がIQueryable<Book>であることから分かるように、クエリ変数は結果を保持しておらず、代わりにクエリのコマンドが格納されるだけです。

IQueryable<T>からクエリが実際に実行されるタイミングは

  • foreachによるループ処理をする時
  • Single Maxなど一つの値を得る時
  • ToListなどで列挙する時

の3つです。

※追記:いただいたコメントによると以下の方法で簡単に見分けられるそうです。

LINQメソッドが即時実行か遅延実行か見分ける簡単な方法は、返り値の型を見ることです。
返り値の型がIEnumerableかSystem.Linq名前空間のインターフェイスなら遅延実行。

foreachループによるクエリの実行

IQueryable<T>(のExspressionプロパティ)に格納されたコマンドは、foreachによってループ処理されるときに実際に実行されます。

using (var dbContext = new BooksDbContext()) {
  // まだクエリは実行されていない。query は結果のデータでなく式を持っている状態
  IQueryable<string> query = dbContext.Books
    .Where(b => b.PublishYear < 1970)
    .Select(b => b.Title);

  // foreach ループで初めてクエリが流れてデータストアからデータを取得する
  foreach (var title in query) { 
    Console.WriteLine(title);
  }
}

1つの値を返す LINQ によるクエリの実行

1つの値だけを返すクエリは即時に実行されます。
Average Max Min Count First FirstOrDefault Single...などです。

using (var dbContext = new BooksDbContext()) {
  // データストアからデータを取得した結果が変数に格納される
  double averagePrice = dbContext.Books
    .Where(b => b.AuthorName == "アーサー・C・クラーク")
    .Average(b => b.Price);
  Console.WriteLine(averagePrice);

  // データストアからデータを取得した結果が変数に格納される
  Book book = dbContext.Books.FirstOrDefault(b => b.ID == 1);
  Console.WriteLine(book.Title);
}

結果を列挙する時のクエリの実行

ToList ToDictionary ToArray などシーケンス(IEnumerable<T>)を返すメソッドはクエリを即時実行します。
(※追記AsEnumerableは即時実行ではありませんでした。コメントありがとうございます。)

using (var dbContext = new BooksDbContext()) {
  // クエリが即時実行され、変数に結果が List<T> として格納される
  List<string> titles = dbContext.Books
    .Select(b => b.Title)
    .ToList();
  Console.WriteLine(titles[2]); // List<T>(既に結果取得済み)になっている
}

using (var dbContext = new BooksDbContext()) {
  // クエリが即時実行され、変数に結果が Dictionary<TKey, TValue> として格納される
  IDictionary<int, string> dic = dbContext.Books
    .Where(book => book.Price > 3000)
    .ToDictionary(book => book.ID, Book => Book.Title);
  if (dic.TryGetValue(key: 1, out string title)) {  // used C# 7.3
    Console.WriteLine(title);
  }
}

クエリの合成

クエリの実行を遅延できるので、クエリを合成することも可能です。
以下の例ではforeachまでクエリの実行が遅延されるのでWhereによるフィルタリング処理も含んだクエリが実行されます。

using (var dbContext = new BooksDbContext()) {
  // クエリはまだ実行されず、式を保持している状態
  var query = dbContext.Books.Select(book => book.Title);

  // まだクエリが実行されていないので取得結果をフィルタリングするわけでなく、
  // 式に対してフィルタリング処理を追加できる
  if (!string.IsNullOrWhiteSpace(serchString)) {
    query = query.Where(title => title.Contains(serchString));
  }

  // ここでクエリが実行される
  foreach (var title in query) {
    Console.WriteLine(title);
  }
}

LINQ to Entities から LINQ to Objects に切り替わるタイミングの見逃し

上記のように便利な LINQ to Entities ですが、クエリが実行されるタイミングを把握していないと、うっかり非効率なクエリを書いてしまうことがあります。

以下はC#をタイトルに含む書籍のタイトル一覧を取得するクエリです。

// SQL は実行されておらず式を保持している状態
var query = dbContext.Books
  .Select(book => book.Title)
  .Where(title => title.Contains("C#"));

// foreach による反復処理時に SQL が発行される
foreach (var title in query) {
  Console.WriteLine(title);
}

クエリに途中にAsEnumerableが入るとどうでしょうか。
以下の例ではforeachによる列挙でクエリが実行される際に、DB から書籍のタイトルを全て取得するクエリが実行されAsEnumerable()以降のフィルタリングは LINQ to Objects によってメモリ内でが行われます。
(※追記:記事内で「遅延実行と即時実行」と「LINQ to Entities と LINQ to Objectsが切り替わるタイミング」がごっちゃになっていましたので表現を修正しました。詳しくはコメント欄をご確認ください。)

// クエリはまだ実行されていない
var query = dbContext.Books
  .Select(book => book.Title)
  .AsEnumerable() // <- IEnumerable<T> に切り替わる
  .Where(title => title.Contains("C#"));

// クエリが実行される
foreach (var title in query) {
  Console.WriteLine(title);
}

通信量が増え、1つ目のクエリと比べ、はるかに遅くなる可能性が高いです。
上記の例では途中にAsEnumerableが挟まっているのでわかりやすいですが、クエリが複雑になったりメソッドが分割されているときは注意が必要です。

また、それ以外でも、データストアからデータを取得するメソッドを定義した際、結果を返すのではなく、IQueryable<T>を返してしまい、実際にクエリが実行されるときにはデータストアとの接続が切れていて例外をスローすることも起こります。(実体験)
やはり、LINQ to Entities でいつクエリが実行されるかを把握しておくことは重要だと思います。


2年目プログラマーです。
エンジニアの方とつながれるととても嬉しいです!

twitter: のさ@nosa_programmer