1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

EF Core 5.0のTablePerTypeマッピング

Last updated at Posted at 2021-08-11

はじめに

EF Core 5.0では階層化されたエンティティのマッピング方法として、従来のTablePerHierarchy(階層単位のマッピング)とは別にTablePerType(テーブル単位のマッピング)が追加されました。この記事ではEF Core 5.0でTablePerTypeの階層化マッピングを作成する方法と注意点について説明します。

接続データベースにMySQL、データベースアダプターにPomelo.EntityFrameworkCore.MySqlを利用しています。

継承関係にあるエンティティーの定義

次のような継承関係を持つエンティティーがあったとします。
@startuml.png
コードにするとこんな感じですね。

    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に定義しないと、TestATestBは全く関係のないテーブルになってしまうので注意してください。

AppDbContext.cs
    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に対して登録しても、子クラスであるTestATestBに登録しても結果は変わりません。

    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テーブルには次のようにデータが格納されています。TestAPropATestBPropBTestという1つのテーブルに定義され、関係ないプロパティーにはnullが格納されているのが分かります。また、エンティティーを識別するDiscriminatorというカラムが自動的に追加され、そのレコードがTestAのものなのか、TestBのものなのかが判別できるようになっています。

image.png
TestAから1行読み出してみると、

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メソッドを使って配置するテーブル名を指定しています。

AppDbContext.cs
    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図にするとクラス定義と同じ形にマッピングされていることが確認できます。個人的にはこちらの方がすっきりしてよさそうな気がしますね。

image.png
データを追加すると、親である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();

image.png
先ほどと同じように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件のデータを挿入し、全件読み込む速度を計測したものです。
@startuml.png

階層単位のマッピング
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(テーブル単位のマッピング)がある
  • どちらを利用するかは、それぞれの利用用途に応じて検討する必要がある
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?