はじめに
EF Core 5.0では階層化されたエンティティのマッピング方法として、従来のTablePerHierarchy(階層単位のマッピング)とは別にTablePerType(テーブル単位のマッピング)が追加されました。この記事ではEF Core 5.0でTablePerTypeの階層化マッピングを作成する方法と注意点について説明します。
接続データベースにMySQL、データベースアダプターにPomelo.EntityFrameworkCore.MySqlを利用しています。
継承関係にあるエンティティーの定義
次のような継承関係を持つエンティティーがあったとします。
コードにするとこんな感じですね。
public abstract class Test
{
public int Id { get; set; }
public string Name { get; set; }
}
public class TestA : Test
{
public string PropA { get; set; }
}
public class TestB : Test
{
public string PropB { get; set; }
}
EF Core 5.0ではこのようなエンティティーをデータベースに反映する場合、2つの手段をとることができます。
- 階層単位のマッピング(以前から利用可能)
- テーブル単位のマッピング(EF Core 5から利用可能)
階層単位のマッピング
まずは従来から利用可能な階層単位のマッピングでデータベースに表定義を反映していきましょう。
DataContextを下記のように定義します。ここでTest
をDbContextに定義しないと、TestA
とTestB
は全く関係のないテーブルになってしまうので注意してください。
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> dbContextOptions)
: base(dbContextOptions)
{
}
public DbSet<Test> Test { get; set; }
public DbSet<TestA> TestA { get; set; }
public DbSet<TestB> TestB { get; set; }
}
データベースマイグレーションを実施すると下記のようなSQLが発行されます。
CREATE TABLE `Test` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext CHARACTER SET utf8mb4 NULL,
`Discriminator` longtext CHARACTER SET utf8mb4 NOT NULL,
`PropA` longtext CHARACTER SET utf8mb4 NULL,
`PropB` longtext CHARACTER SET utf8mb4 NULL,
CONSTRAINT `PK_Test` PRIMARY KEY (`Id`)
) CHARACTER SET utf8mb4;
次のコードでデータベースにサンプルデータを追加していきます。データの登録時は親クラスであるTest
に対して登録しても、子クラスであるTestA
やTestB
に登録しても結果は変わりません。
await _dbContext.TestA.AddAsync(new TestA { Name = "TestA_1", PropA = "12345"});
await _dbContext.TestB.AddAsync(new TestB { Name = "TestB_1", PropB = "67890"});
await _dbContext.Test.AddAsync(new TestA { Name = "TestA_2", PropA = "ABC"});
await _dbContext.Test.AddAsync(new TestB { Name = "TestB_2", PropB = "DEF"});
await _dbContext.SaveChangesAsync();
Test
テーブルには次のようにデータが格納されています。TestA
のPropA
とTestB
のPropB
がTest
という1つのテーブルに定義され、関係ないプロパティーにはnull
が格納されているのが分かります。また、エンティティーを識別するDiscriminator
というカラムが自動的に追加され、そのレコードがTestA
のものなのか、TestB
のものなのかが判別できるようになっています。
Console.WriteLine(await _dbContext.TestA.FirstAsync(r => r.PropA == "12345"));
こんなSQLが発行されました。このSQLだったらDiscriminator
にIndexを張りたい気がしますね(まぁPropAで検索している時点でダメダメなんですけれど)。
SELECT `t`.`Id`, `t`.`Discriminator`, `t`.`Name`, `t`.`PropA`
FROM `Test` AS `t`
WHERE (`t`.`Discriminator` = 'TestA') AND (`t`.`PropA` = '12345')
LIMIT 1
テーブル単位のマッピング
続いてEF Core 5.0から利用になったテーブルの単位マッピングです。OnModelCreating時にToTableメソッドを使って配置するテーブル名を指定しています。
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> dbContextOptions)
: base(dbContextOptions)
{
}
public DbSet<Test> Test { get; set; }
public DbSet<TestA> TestA { get; set; }
public DbSet<TestB> TestB { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TestA>().ToTable(nameof(TestA));
modelBuilder.Entity<TestB>().ToTable(nameof(TestB));
}
}
マイグレーション時には、下記のようなDDLが発行されます。Testテーブル
とTestAテーブル
、TestBテーブル
の3つのテーブルが作成され、ON DELETE CASCADEオプション付きで外部制約が張られていることが確認できます。
CREATE TABLE `Test` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext CHARACTER SET utf8mb4 NULL,
CONSTRAINT `PK_Test` PRIMARY KEY (`Id`)
) CHARACTER SET utf8mb4;
CREATE TABLE `TestA` (
`Id` int NOT NULL AUTO_INCREMENT,
`PropA` longtext CHARACTER SET utf8mb4 NULL,
CONSTRAINT `PK_TestA` PRIMARY KEY (`Id`),
CONSTRAINT `FK_TestA_Test_Id` FOREIGN KEY (`Id`) REFERENCES `Test` (`Id`) ON DELETE CASCADE
) CHARACTER SET utf8mb4;
CREATE TABLE `TestB` (
`Id` int NOT NULL AUTO_INCREMENT,
`PropB` longtext CHARACTER SET utf8mb4 NULL,
CONSTRAINT `PK_TestB` PRIMARY KEY (`Id`),
CONSTRAINT `FK_TestB_Test_Id` FOREIGN KEY (`Id`) REFERENCES `Test` (`Id`) ON DELETE CASCADE
) CHARACTER SET utf8mb4;
ER図にするとクラス定義と同じ形にマッピングされていることが確認できます。個人的にはこちらの方がすっきりしてよさそうな気がしますね。
データを追加すると、親であるTestテーブル
と子供であるTestAテーブル
、TestBテーブル
の両方にちゃんとデータ格納されていることが分かります。
await _dbContext.TestA.AddAsync(new TestA { Name = "TestA_1", PropA = "12345"});
await _dbContext.TestB.AddAsync(new TestB { Name = "TestB_2", PropB = "67890"});
await _dbContext.Test.AddAsync(new TestA { Name = "TestA_1", PropA = "ABC"});
await _dbContext.Test.AddAsync(new TestB { Name = "TestB_1", PropB = "DEF"});
await _dbContext.SaveChangesAsync();
先ほどと同じように1行読み出してみると、下記のようにTestテーブル
とTestAテーブル
が結合されて取得されているのが分かります。
SELECT `t`.`Id`, `t`.`Name`, `t0`.`PropA`
FROM `Test` AS `t`
INNER JOIN `TestA` AS `t0` ON `t`.`Id` = `t0`.`Id`
WHERE `t0`.`PropA` = '12345'
LIMIT 1
どっちが良いんだろう
出来上がるテーブルの構造だけ見るとテーブル単位マッピング
のほうが素直に実装されているので何となくこちらの方がよさそうに見えます。ただし、EF Coreのパフォーマンスマッピング - 継承のマッピングというトピックでは、Joinは負荷の高い処理であるため従来の階層単位のマッピング
のほうがパフォーマンスが良くなるという記載があります。
例にあるクラスを実際にローカルのコンテナで実行しているMySQLに対してパフォーマンスを計測してみました。
この例ではRootクラスと継承関係にある6つのエンティティー(Child1
, Child1A
, Child1B
, Child2
, Child2A
, Child2B
)に対してそれぞれ5000件のデータを挿入し、全件読み込む速度を計測したものです。
Inheritance.TPH: DefaultJob [RowsPerEntityType=5000]
Runtime = .NET 5.0.8 (5.0.821.31504), X64 RyuJIT; GC = Concurrent Workstation
Mean = 205.444 ms, StdErr = 1.596 ms (0.78%), N = 99, StdDev = 15.876 ms
Min = 181.314 ms, Q1 = 194.081 ms, Median = 199.921 ms, Q3 = 215.525 ms, Max = 242.547 ms
IQR = 21.444 ms, LowerFence = 161.914 ms, UpperFence = 247.691 ms
ConfidenceInterval = [200.031 ms; 210.858 ms] (CI 99.9%), Margin = 5.413 ms (2.63% of Mean)
Skewness = 0.92, Kurtosis = 2.67, MValue = 2.2
-------------------- Histogram --------------------
[176.810 ms ; 182.918 ms) | @
[182.918 ms ; 191.195 ms) | @@@@@@@@@@
[191.195 ms ; 200.203 ms) | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
[200.203 ms ; 209.577 ms) | @@@@@@@@@@@@@@@@@@@
[209.577 ms ; 219.541 ms) | @@@@@@@
[219.541 ms ; 228.550 ms) | @@@@@@@@@@
[228.550 ms ; 241.748 ms) | @@@@@@@@@@@
[241.748 ms ; 247.051 ms) | @
---------------------------------------------------
Inheritance.TPT: DefaultJob [RowsPerEntityType=5000]
Runtime = .NET 5.0.8 (5.0.821.31504), X64 RyuJIT; GC = Concurrent Workstation
Mean = 532.521 ms, StdErr = 3.827 ms (0.72%), N = 98, StdDev = 37.888 ms
Min = 467.127 ms, Q1 = 503.789 ms, Median = 523.570 ms, Q3 = 553.880 ms, Max = 634.075 ms
IQR = 50.092 ms, LowerFence = 428.651 ms, UpperFence = 629.018 ms
ConfidenceInterval = [519.533 ms; 545.510 ms] (CI 99.9%), Margin = 12.989 ms (2.44% of Mean)
Skewness = 0.78, Kurtosis = 2.89, MValue = 2
-------------------- Histogram --------------------
[456.341 ms ; 480.340 ms) | @
[480.340 ms ; 503.044 ms) | @@@@@@@@@@@@@@@@@@@@@@
[503.044 ms ; 526.901 ms) | @@@@@@@@@@@@@@@@@@@@@@@@@@@@
[526.901 ms ; 548.473 ms) | @@@@@@@@@@@@@@@@@@
[548.473 ms ; 573.524 ms) | @@@@@@@@@@@@
[573.524 ms ; 595.096 ms) | @@@@@@@@@@
[595.096 ms ; 622.916 ms) | @@@@@@
[622.916 ms ; 644.861 ms) | @
---------------------------------------------------
確かに階層単位のマッピング
の平均が205.444 msなのに対し、テーブル単位マッピング
は532.521 msかかっています。考えてみれば、階層単位のマッピング
は単一テーブルをそのままSelectするのに対し、テーブル単位マッピング
はRootテーブルと子供テーブルを常にJoinする必要があるのでわからなくもないです。
どちらを利用するかは、メンテナンスコストとパフォーマンスコストのどちらを優先するかをしっかりと検討する必要がありそうですね。
まとめ
- 継承関係にあるエンティティーの表現方法として、TablePerHierarchy(階層単位のマッピング)とTablePerType(テーブル単位のマッピング)がある
- どちらを利用するかは、それぞれの利用用途に応じて検討する必要がある