Entity Framework Core - IncludeとThenIncludeを試す
IncludeメソッドとThenIncludeメソッドは、あわせて読み込む関連エンティティを指定するために使います。Eager loadingと呼ばれている機能ですね。
Loading Related Data - EF Core | Microsoft Docs
このIncludeメソッドとThenIncludeメソッドを使うとどんなSQLが実行されるのか気になったので試してみました。実行されたSQLのログをペタペタとはっていきたいと思います。
コード全体やクエリはこちらに。
EF CoreのIncludeとThenInclude · GitHub
モデルとかデータとか
まず次のようなモデルを用意してみました。1対多の関係が1つ、多対多の関係が2つあります。1対多の関係が全部で5つと言ったほうがいいのか。
// MonsterCategory - Monster : 1対多 // Monster - MonsterArea - Area : 多対多 // Monster - MonsterItem - Item : 多対多 // 地域 public class Area { public int Id { get; set; } public string Name { get; set; } } // アイテム public class Item { public int Id { get; set; } public string Name { get; set; } } // モンスターカテゴリ public class MonsterCategory { public int Id { get; set; } public string Name { get; set; } public List<Monster> Monsters { get; set; } } // モンスター public class Monster { public int Id { get; set; } public string Name { get; set; } public int CategoryId { get; set; } public MonsterCategory Category { get; set; } public List<MonsterArea> Areas { get; set; } public List<MonsterItem> Items { get; set; } } // モンスター生息地 public class MonsterArea { public int MonsterId { get; set; } public int No { get; set; } public int AreaId { get; set; } public Monster Monster { get; set; } public Area Area { get; set; } } // モンスター保持アイテム public class MonsterItem { public int MonsterId { get; set; } public int No { get; set; } public int ItemId { get; set; } public Monster Monster { get; set; } public Item Item { get; set; } }
次にロガーとDBコンテキストを用意します。
// ロガープロバイダー public class AppLoggerProvider : ILoggerProvider { public ILogger CreateLogger(string categoryName) { if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) { return new ConsoleLogger(); } return NullLogger.Instance; } public void Dispose() { } // ロガー private class ConsoleLogger : ILogger { public IDisposable BeginScope<TState>(TState state) => null; // 情報レベル以上のログを有効にする public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { Console.WriteLine(formatter(state, exception)); Console.WriteLine("---"); } } } // DBコンテキスト public class AppDbContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var connectionString = new SqlConnectionStringBuilder { DataSource = ".", InitialCatalog = "Test", IntegratedSecurity = true, }.ToString(); optionsBuilder.UseSqlServer(connectionString); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Area>().ToTable(nameof(Area)); modelBuilder.Entity<Item>().ToTable(nameof(Item)); modelBuilder.Entity<MonsterCategory>().ToTable(nameof(MonsterCategory)); modelBuilder.Entity<Monster>().ToTable(nameof(Monster)); modelBuilder.Entity<MonsterArea>().ToTable(nameof(MonsterArea)) // 複合主キーのマッピング .HasKey(area => new { area.MonsterId, area.No }); modelBuilder.Entity<MonsterItem>().ToTable(nameof(MonsterItem)) .HasKey(item => new { item.MonsterId, item.No }); } }
データを投入しておきましょう。
// 地域 dbContext.Set<Area>().AddRange( new Area { Id = 1, Name = "サンタローズ" }, new Area { Id = 2, Name = "レヌール" }, new Area { Id = 3, Name = "ラインハット" }); // アイテム dbContext.Set<Item>().AddRange( new Item { Id = 1, Name = "やくそう" }, new Item { Id = 2, Name = "どくけしそう" }); // モンスターカテゴリ dbContext.Set<MonsterCategory>().AddRange( new MonsterCategory { Id = 1, Name = "スライム" }, new MonsterCategory { Id = 2, Name = "悪魔" }); // モンスター(とモンスター生息地・モンスター保持アイテム) dbContext.Set<Monster>().AddRange( new Monster { Id = 1, Name = "スライム", CategoryId = 1, // スライム Areas = new List<MonsterArea> { new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ }, Items = new List<MonsterItem> { new MonsterItem { No = 1, ItemId = 1 }, // やくそう }, }, new Monster { Id = 2, Name = "ドラキー", CategoryId = 2, // 悪魔 Areas = new List<MonsterArea> { new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ new MonsterArea { No = 2, AreaId = 2 }, // レヌール }, }, new Monster { Id = 3, Name = "バブルスライム", CategoryId = 1, // スライム Areas = new List<MonsterArea> { new MonsterArea { No = 1, AreaId = 3 }, // ラインハット }, Items = new List<MonsterItem> { new MonsterItem { No = 1, ItemId = 1 }, // やくそう new MonsterItem { No = 2, ItemId = 2 }, // どくけしそう }, }); dbContext.SaveChanges();
Includeメソッドで1対多の1を読み込む
まずはIncludeメソッドを使って1対多の多から1を読み込んでみます。
// モンスターカテゴリを含めてモンスターを読み込む var monsters = dbContext.Set<Monster>() .Include(monster => monster.Category) .ToList(); foreach (var monster in monsters) { Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}]"); } /* #1 スライム [スライム] #2 ドラキー [悪魔] #3 バブルスライム [スライム] */
ログを確認してみます。実行されるSQLはINNER JOINされてます。
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name], [monster.Category].[Id], [monster.Category].[Name] FROM [Monster] AS [monster] INNER JOIN [MonsterCategory] AS [monster.Category] ON [monster].[CategoryId] = [monster.Category].[Id] ---
Includeメソッドで1対多の多を読み込む
今度はIncludeメソッドを使って1対多の1から多を読み込んでみます。
var monsters = dbContext.Set<Monster>() .Include(monster => monster.Areas) .Include(monster => monster.Items) .ToList(); foreach (var monster in monsters) { var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId)); var items = string.Join(", ", monster.Items.Select(item => item.ItemId)); Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}"); } /* #1 スライム in 1 has 1 #2 ドラキー in 1, 2 has #3 バブルスライム in 3 has 1, 2 */
ログを確認すると、SELECT文が3つ実行されていることがわかります。どうもIncludeするごとにSELECT文が増える様子。こんなもの?
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name] FROM [Monster] AS [monster] ORDER BY [monster].[Id] --- Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId] FROM [MonsterArea] AS [monster.Areas] INNER JOIN ( SELECT [monster0].[Id] FROM [Monster] AS [monster0] ) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id] ORDER BY [t].[Id] --- Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId] FROM [MonsterItem] AS [monster.Items] INNER JOIN ( SELECT [monster1].[Id] FROM [Monster] AS [monster1] ) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id] ORDER BY [t0].[Id] ---
Includeメソッドで多を読み込み、さらにThenIncludeメソッドで1を読み込む
ThenIncludeメソッドを使うと、Includeメソッドで指定した関連エンティティのさらに関連エンティティを読み込むことができます。
前述のサンプルにさらにThenIncludeメソッドを加えると、多対多のエンティティを一度に取得できます。確認してみましょう。
var monsters = dbContext.Set<Monster>() .Include(monster => monster.Areas) .ThenInclude(monsterArea => monsterArea.Area) .Include(monster => monster.Items) .ThenInclude(monsterItem => monsterItem.Item) .ToList(); foreach (var monster in monsters) { var areas = string.Join(", ", monster.Areas.Select(area => area.Area.Name)); var items = string.Join(", ", monster.Items.Select(item => item.Item.Name)); Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}"); } /* #1 スライム in サンタローズ has やくそう #2 ドラキー in サンタローズ, レヌール has #3 バブルスライム in ラインハット has やくそう, どくけしそう */
多対1の多から1をThenIncludeしているので、SQLも前述のサンプルにThenIncludeした分のINNER JOINが加わった感じです。
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name] FROM [Monster] AS [monster] ORDER BY [monster].[Id] --- Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId], [m.Area].[Id], [m.Area].[Name] FROM [MonsterArea] AS [monster.Areas] INNER JOIN [Area] AS [m.Area] ON [monster.Areas].[AreaId] = [m.Area].[Id] INNER JOIN ( SELECT [monster0].[Id] FROM [Monster] AS [monster0] ) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id] ORDER BY [t].[Id] --- Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId], [m.Item].[Id], [m.Item].[Name] FROM [MonsterItem] AS [monster.Items] INNER JOIN [Item] AS [m.Item] ON [monster.Items].[ItemId] = [m.Item].[Id] INNER JOIN ( SELECT [monster1].[Id] FROM [Monster] AS [monster1] ) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id] ORDER BY [t0].[Id] ---
Includeメソッドで多を読み込み、さらにThenIncludeメソッドで多を読み込む
ThenIncludeメソッドでの読み込みをもう1つ試してみましょう。多を読み込んでさらに多を読み込みます。
// モンスターとモンスター生息地を含めて、モンスターカテゴリを読み込む var categories = dbContext.Set<MonsterCategory>() .Include(category => category.Monsters) .ThenInclude(monster => monster.Areas) .ToList(); foreach (var monster in categories.SelectMany(category => category.Monsters)) { var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId)); Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}] in {areas}"); } /* #1 スライム [スライム] in 1 #3 バブルスライム [スライム] in 3 #2 ドラキー [悪魔] in 1, 2 */
実行されるSQLは、多をIncludeまたはThenIncludeするごとにSELECT文が増えていく感じでしょうか。
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [category].[Id], [category].[Name] FROM [MonsterCategory] AS [category] ORDER BY [category].[Id] --- Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [category.Monsters].[Id], [category.Monsters].[CategoryId], [category.Monsters].[Name] FROM [Monster] AS [category.Monsters] INNER JOIN ( SELECT [category0].[Id] FROM [MonsterCategory] AS [category0] ) AS [t] ON [category.Monsters].[CategoryId] = [t].[Id] ORDER BY [t].[Id], [category.Monsters].[Id] --- Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [category.Monsters.Areas].[MonsterId], [category.Monsters.Areas].[No], [category.Monsters.Areas].[AreaId] FROM [MonsterArea] AS [category.Monsters.Areas] INNER JOIN ( SELECT DISTINCT [category.Monsters0].[Id], [t0].[Id] AS [Id0] FROM [Monster] AS [category.Monsters0] INNER JOIN ( SELECT [category1].[Id] FROM [MonsterCategory] AS [category1] ) AS [t0] ON [category.Monsters0].[CategoryId] = [t0].[Id] ) AS [t1] ON [category.Monsters.Areas].[MonsterId] = [t1].[Id] ORDER BY [t1].[Id0], [t1].[Id] ---
IncludeメソッドやThenIncludeメソッドを使うとSELECT文が複数実行される場合があるということがなんだか気になるところでした。
Entity Framework Core - SQLをログで確認する
EF Coreでやってみたいことは色々ありますが、まずは実行されるSQLを確認できるようにしておきたいのでログまわりを少し試します。
ドキュメントだとこのあたりですね。
Logging - EF Core | Microsoft Docs
ログを出力する
上記ドキュメントそのままなんですが、ログを出力するためにまずはILoggerProvider
とILogger
を実装します。
// ロガープロバイダー public class AppLoggerProvider : ILoggerProvider { // ロガーを生成 public ILogger CreateLogger(string categoryName) { return new ConsoleLogger(); } public void Dispose() { } // ロガー private class ConsoleLogger : ILogger { public IDisposable BeginScope<TState>(TState state) => null; public bool IsEnabled(LogLevel logLevel) => true; // ログを出力 public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { // コンソールに出力 Console.WriteLine(formatter(state, exception)); } } }
コンソールアプリでは実装したロガープロバイダーをDBコンテキストに設定します。(エンティティとDBコンテキストのコードは前回の記事を参考にしてもらえると。)
using (var dbContext = new AppDbContext()) { // ロガープロバイダーを設定する var serviceProvider = dbContext.GetInfrastructure(); var loggerFactory = serviceProvider.GetService<ILoggerFactory>(); loggerFactory.AddProvider(new AppLoggerProvider()); // データを取得 var monsters = dbContext.Monsters.ToList(); foreach (var monster in monsters) { Console.WriteLine($"#{monster.Id} {monster.Name}"); } //#1 スライム //#2 ドラキー }
実行すると次のようなログがわっさーと出てきます。ちょっと見づらいですが、コネクションを開いたり閉じたりしたログ、SELECT文を実行したログなどが確認できますね。
Compiling query model: 'from Monster <generated>_0 in DbSet<Monster> select [<generated>_0]' Optimized query model: 'from Monster <generated>_0 in DbSet<Monster> select [<generated>_0]' (QueryContext queryContext) => IEnumerable<Monster> _InterceptExceptions( source: IEnumerable<Monster> _TrackEntities( results: IEnumerable<Monster> _ShapedQuery( queryContext: queryContext, shaperCommandContext: SelectExpression: SELECT [m].[Id], [m].[Name] FROM [Monster] AS [m], shaper: UnbufferedEntityShaper<Monster>), queryContext: queryContext, entityTrackingInfos: { itemType: Monster }, entityAccessors: List<Func<Monster, object>> { Func<Monster, Monster>, } ), contextType: ConsoleApp.AppDbContext, logger: DiagnosticsLogger<Query>, queryContext: queryContext) Opening connection to database 'Test' on server '.'. Opened connection to database 'Test' on server '.'. Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [m].[Id], [m].[Name] FROM [Monster] AS [m] Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [m].[Id], [m].[Name] FROM [Monster] AS [m] A data reader was disposed. Closing connection to database 'Test' on server '.'. Closed connection to database 'Test' on server '.'.
ログをフィルタする
このままではちょっと情報が多いので、実行されるSQLだけを確認したいという体でログをフィルタしたいと思います。
ILoggerProvider.CreateLogger
メソッドの引数にcategoryName
があります。ログにはいくつかカテゴリがあるようで、おそらくDbLoggerCategory
で定義されているのがそれです。
ソースを見た+試してみたところ、DbLoggerCategory.Database.Command
がSQLの実行に関係してそうです。これを使ってフィルタするには次のようなコードになるのかなと思います。
// ロガープロバイダー public class AppLoggerProvider : ILoggerProvider { // ロガーを生成 public ILogger CreateLogger(string categoryName) { // SQLの実行に関するログだけ出力する if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) { return new ConsoleLogger(); } // 何も出力しないロガー return NullLogger.Instance; } public void Dispose() { } // ロガー private class ConsoleLogger : ILogger { public IDisposable BeginScope<TState>(TState state) => null; public bool IsEnabled(LogLevel logLevel) => true; // ログを書き込む public void Log<TState>( LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { // ログをコンソールに出力 // LogLevelとEventIdも出力してみる Console.WriteLine($"{nameof(logLevel)}: {logLevel}"); Console.WriteLine($"{nameof(eventId)}: {eventId}"); Console.WriteLine(formatter(state, exception)); Console.WriteLine("---"); } } }
このロガーで再度実行すると次のようなログが出力されます。
logLevel: Debug eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuting Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [m].[Id], [m].[Name] FROM [Monster] AS [m] --- logLevel: Information eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted Executed DbCommand (43ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [m].[Id], [m].[Name] FROM [Monster] AS [m] --- logLevel: Debug eventId: Microsoft.EntityFrameworkCore.Database.Command.DataReaderDisposing A data reader was disposed. ---
ILogger.Log
メソッドの引数にあるLogLevel
やEventId
も気になったので出力してみましたが、このパラメータを使ってフィルタしたり、出力先を変えたりすることもできそうですね。
ちなみにEventId
はRelationalEventId
で定義されています。
今回はこんなところで。
Entity Framework Coreはじめました
EF Coreを試しはじめました。先はあまり考えていませんが、色々試したことを書き残していけるといいなと思います。とりあえず今回は最初なのでSQL Serverにあるデータを取得してみます。
データの用意
まずはSQL Server Management Studioでデータを用意しておきます。
-- テーブルを作る drop table if exists dbo.Monster; create table dbo.Monster( Id int, Name nvarchar(20), constraint PK_Monster primary key(Id) ); -- データを投入 insert into dbo.Monster(Id, Name) output inserted.* values (1, N'スライム'), (2, N'ドラキー'); /* Id Name ----------- -------------------- 1 スライム 2 ドラキー */
EF Coreのインストール
ここからはVisual Studioです。.NET Coreのコンソールアプリプロジェクトを作成してNugetで必要なものを取得しましょう。パッケージマネージャーコンソールで以下を実行です。
Install-Package Microsoft.EntityFrameworkCore.SqlServer
エンティティとDBコンテキストの用意
エンティティとDBコンテキストを用意します。
EF6とは違って接続文字列はOnConfiguring
メソッド内で指定するみたいです。
個人的な好みでテーブル名は単数形にしたいのですが、EF6にあったPluralizingTableNameConvention
のようなクラスはどうもなさそうな?(しっかり調べていない)ので、愚直にToTable
メソッドでエンティティをテーブルにマッピングしています。
// エンティティ public class Monster { public int Id { get; set; } public string Name { get; set; } } // DBコンテキスト public class AppDbContext : DbContext { public DbSet<Monster> Monsters { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // 接続文字列を指定する var connectionString = new SqlConnectionStringBuilder { DataSource = ".", InitialCatalog = "Test", IntegratedSecurity = true, }.ToString(); optionsBuilder.UseSqlServer(connectionString); } protected override void OnModelCreating(ModelBuilder modelBuilder) { // テーブルにマッピングする modelBuilder.Entity<Monster>().ToTable("Monster"); } }
データの取得
データを取得してみます。このあたりはEF6と同じですね。
using(var dbContext = new AppDbContext()) { // データを取得 var monsters = dbContext.Monsters.ToList(); foreach (var monster in monsters) { Console.WriteLine($"#{monster.Id} {monster.Name}"); } //#1 スライム //#2 ドラキー }
次の環境で確認しています。
- Visual Studio 2017
- .NET Core 2.0
- EF Core 2.0
- SQL Server 2016
今回はこのへんで。
参考
FORMATMESSAGE関数を試す
SQL Server 2016からFORMATMESSAGE関数が拡張されて、プレイスホルダーを使って文字列を組み立てられるようになりました。
FORMATMESSAGE (Transact-SQL) | Microsoft Docs
C言語のprintfみたい感じですかね。たぶん。
文字列を埋め込むには%s
を使います。
-- 文字列を埋め込む print formatmessage(N'Hello, %s!', N'World'); /* Hello, World! */
数字の埋め込みには%d
や%u
、%x
などを使います。
-- 整数を埋め込む print formatmessage(N'Hello, %d!', 1); /* Hello, 1! */ -- 符号なし整数、符号なし16進数を埋め込む declare @value int = 10; print formatmessage(N'10進数 %u => 16進数 %x', @value, @value); /* 10進数 10 => 16進数 a */
詳しい書式の説明は、こちらのRAISERROR関数のドキュメントにあります。
STRING_SPLIT関数で文字列を分割する
SQL Server 2016から使えるSTRING_SPLIT関数を試してみます。
STRING_SPLIT (Transact-SQL) | Microsoft Docs
この関数は1つ目の引数の文字列を2つ目の引数の文字で分割します。戻り値はvalueカラムを1つだけ持つ表形式の結果セットです。
-- ','で分割する select * from string_split(N'abc,def,ghi', N','); -- 結果 /* value ----------- abc def ghi */
1つ目の引数がnullの場合、戻り値は空の結果セット(レコードが0件)になります。分割できない場合は、引数の値がそのまま返ってきます。
-- 引数がnullの場合は空の結果セット select * from string_split(null, N','); /* value ----- */ -- 引数が分割できない場合は引数の値 select * from string_split(N'abc', N','); /* value ----- abc */
また2つ目の引数に文字を複数指定するとエラーになります。
-- エラーになる select * from string_split(N'abc_def-ghi', N'_-'); /* プロシージャでは型 'nchar(1)/nvarchar(1)' のパラメーター 'separator' を想定しています。 */