継承

EF では、.NET 型の階層をデータベースにマップできます。 これにより、基本型と派生型を使用して通常どおり .NET エンティティをコードに記述し、EF で適切なデータベース スキーマの作成やクエリの発行などをシームレスに行えます。型階層がマップされる実際の方法の詳細は、プロバイダーに依存します。このページでは、リレーショナル データベースのコンテキストでの継承のサポートについて説明します。

エンティティ型の階層マッピング

慣例により、EF では基本型または派生型が自動的にスキャンされません。つまり、階層内の CLR 型をマップする場合は、モデルでその型を明示的に指定する必要があります。 たとえば、階層の基本型のみを指定した場合、EF Core ではそのすべてのサブ型が暗黙的に含まれなくなります。

次の例では、Blog とそのサブクラス RssBlog の DbSet が公開されています。 Blog に他のサブクラスがある場合、モデルに含まれません。

internal class MyContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<RssBlog> RssBlogs { get; set; }
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
}

public class RssBlog : Blog
{
    public string RssUrl { get; set; }
}

Note

TPH マッピングを使用すると、必要に応じてデータベース列が自動的に Null 許容になります。 たとえば、RssUrl 列は Null 値を許容しますが、これは通常の Blog インスタンスにはそのプロパティがないためです。

階層内の 1 つ以上のエンティティについて DbSet を公開したくない場合は、Fluent API を使用して、モデルに含まれるようにすることもできます。

ヒント

慣例に依拠しない場合、HasBaseType を使用して基本型を明示的に指定できます。 .HasBaseType((Type)null) を使用して、階層からエンティティ型を削除することもできます。

Table-Per-Hierarchy と識別子の構成

既定では、EF では Table-Per-Hierarchy (TPH) パターンを使用して継承をマップします。 TPH では、1 つのテーブルを使用して、階層内のすべての型のデータを格納し、識別子列を使用して、各行が表す型を識別します。

上記のモデルは、次のデータベース スキーマにマップされます (暗黙的に作成された Discriminator 列に注目してください。これは各行に格納される Blog の型を識別します)。

Screenshot of the results of querying the Blog entity hierarchy using table-per-hierarchy pattern

識別子列の名前と型、および階層内の各型を識別するために使用される値を構成できます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator<string>("blog_type")
        .HasValue<Blog>("blog_base")
        .HasValue<RssBlog>("blog_rss");
}

上記の例で、EF では階層の基本エンティティのシャドウ プロパティとして識別子が暗黙的に追加されました。 このプロパティは、他のプロパティと同様に構成できます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property("Discriminator")
        .HasMaxLength(200);
}

最後に、識別子をエンティティの通常の .NET プロパティにマップできます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator(b => b.BlogType);

    modelBuilder.Entity<Blog>()
        .Property(e => e.BlogType)
        .HasMaxLength(200)
        .HasColumnName("blog_type");
        
    modelBuilder.Entity<RssBlog>();
}

TPH パターンを使用する派生エンティティに対してクエリを実行する場合、EF Core ではクエリで識別子列に対して述語が追加されます。 このフィルターにより、基本型または兄弟型の追加の行が結果として取得されなくなります。 基本エンティティ型に対してこのフィルター述語はスキップされます。これは、基本エンティティに対してクエリを実行すると、階層内のすべてのエンティティの結果が取得されるためです。 クエリの結果を具体化するとき、モデル内のどのエンティティ型にもマップされない識別子値が見つかった場合、結果を具体化する方法がわからないため例外がスローされます。 このエラーは、EF モデルでマップされない識別子値を持つ行がデータベースに含まれている場合にのみ発生します。 このようなデータがある場合は、EF Core モデルの識別子マッピングを不完全としてマークし、階層内の任意の型に対してクエリを実行するために、フィルター述語を常に追加する必要があることを示すことができます。 識別子構成に対して IsComplete(false) 呼び出しを行うと、マッピングが不完全とマークされます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator()
        .IsComplete(false);
}

共有列

既定では、階層内の 2 つの兄弟エンティティ型に同じ名前のプロパティがある場合、それらは 2 つの個別の列にマップされます。 ただし、型が同一の場合は、同じデータベース列にマップできます。

public class MyContext : DbContext
{
    public DbSet<BlogBase> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Url)
            .HasColumnName("Url");

        modelBuilder.Entity<RssBlog>()
            .Property(b => b.Url)
            .HasColumnName("Url");
    }
}

public abstract class BlogBase
{
    public int BlogId { get; set; }
}

public class Blog : BlogBase
{
    public string Url { get; set; }
}

public class RssBlog : BlogBase
{
    public string Url { get; set; }
}

Note

SQL Server などのリレーショナル データベース プロバイダーでは、キャストの使用時に共有列に対してクエリを実行するとき、識別子述語が自動的に使用されません。 クエリ Url = (blog as RssBlog).Url では、兄弟の Blog 行の Url 値も返されます。 クエリを RssBlog エンティティに制限するには、Url = blog is RssBlog ? (blog as RssBlog).Url : null などのように、識別子にフィルターを手動で追加する必要があります。

Table-Per-Type 構成

TPT マッピング パターンでは、すべての型が個々のテーブルにマップされます。 基本データ型または派生型のみに属するプロパティは、その型にマップされたテーブルに格納されます。 派生型にマップされるテーブルには、派生テーブルと基本テーブルを結合する外部キーも格納されます。

modelBuilder.Entity<Blog>().ToTable("Blogs");
modelBuilder.Entity<RssBlog>().ToTable("RssBlogs");

ヒント

各エンティティ型で ToTable を呼び出す代わりに、各ルート エンティティ型で modelBuilder.Entity<Blog>().UseTptMappingStrategy() を呼び出すことができます。テーブル名は EF によって生成されます。

ヒント

各テーブルの主キー列に対して異なる列名を構成するには、「テーブル固有のファセット構成」を参照してください。

EF では上記のモデルに対し、次のデータベース スキーマが作成されます。

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
    CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);

Note

主キー制約の名前が変更されると、階層にマップされているすべてのテーブルに新しい名前が適用されます。今後の EF バージョンでは、問題 19970 が修正された場合、特定のテーブルの制約についてのみ名前変更が可能になります。

一括構成を使用している場合は、GetColumnName(IProperty, StoreObjectIdentifier) を呼び出すことによって、特定のテーブルの列名を取得できます。

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    var tableIdentifier = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table);

    Console.WriteLine($"{entityType.DisplayName()}\t\t{tableIdentifier}");
    Console.WriteLine(" Property\tColumn");

    foreach (var property in entityType.GetProperties())
    {
        var columnName = property.GetColumnName(tableIdentifier.Value);
        Console.WriteLine($" {property.Name,-10}\t{columnName}");
    }

    Console.WriteLine();
}

警告

多くの場合、TPT は TPH と比較してパフォーマンスが劣ります。 詳細については、パフォーマンスに関するドキュメントを参照してください

注意事項

派生型の列は異なるテーブルにマップされるため、継承されたプロパティと宣言されたプロパティの両方を使用する複合 FK 制約およびインデックスをデータベースに作成することはできません。

Table-Per-Concrete-Type 構成

Note

Table-Per-Concrete-Type (TPT) 機能は、EF Core 7.0 で導入されました。

TPC マッピング パターンでは、すべての型が個々のテーブルにマップされます。 各テーブルには、対応するエンティティ型のすべてのプロパティの列が含まれています。 これにより、TPT 戦略に関するいくつかの一般的なパフォーマンスの問題に対応できます。

ヒント

EF チームは、.NET Data Community Standup のエピソードで TPC マッピングの詳細について実演および説明しました。 Community Standup のすべてのエピソードと同様に、YouTube で TPC のエピソードを見ることができます。

modelBuilder.Entity<Blog>().UseTpcMappingStrategy()
    .ToTable("Blogs");
modelBuilder.Entity<RssBlog>()
    .ToTable("RssBlogs");

ヒント

各エンティティ型で ToTable を呼び出す代わりに、各ルート エンティティ型で modelBuilder.Entity<Blog>().UseTpcMappingStrategy() を呼び出すと、慣例に従ってテーブル名が生成されます。

ヒント

各テーブルの主キー列に対して異なる列名を構成するには、「テーブル固有のファセット構成」を参照してください。

EF では上記のモデルに対し、次のデータベース スキーマが作成されます。

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);

TPC データベース スキーマ

TPC 戦略は TPT 戦略に似ています。ただし、階層内のすべての "具象" 型に対して異なるテーブルが作成されますが、"抽象" 型にはテーブルが作成されない点が異なります。このため、"Table-Per-Concrete-Type" (具象型ごとのテーブル) という名前が付いています。 TPT と同様に、テーブル自体が保存されているオブジェクトの型を示します。 ただし、TPT マッピングとは異なり、各テーブルには具象型とその基本データ型のすべてのプロパティの列が含まれます。 TPC データベース スキーマは非正規化されています。

たとえば、次のような階層のマッピングについて考えます。

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

SQL Server を使うと、この階層に対して作成されるテーブルは次のようになります。

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

次のことに注意してください。

  • Animal または Pet 型は、オブジェクト モデルにおいて abstract であるため、これらに対するテーブルはありません。 C# では抽象型のインスタンスが許可されないため、抽象型インスタンスがデータベースに保存されるような状況はないことに注意してください。

  • 基本データ型でのプロパティのマッピングが、具象型ごとに繰り返されます。 たとえば、すべてのテーブルに Name 列があり、Cats と Dogs の両方に Vet 列があります。

  • このデータベースにデータを保存すると、次のようになります。

Cats テーブル

Id 名前 FoodId Vet EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Preschool
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell Pet Hospital BSc

Dogs テーブル

Id 名前 FoodId Vet FavoriteToy
3 トースト 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Mr. Squirrel

FarmAnimals テーブル

Id 名前 FoodId Species
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Humans テーブル

Id 名前 FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie null 8

TPT マッピングとは異なり、1 つのオブジェクトのすべての情報が 1 つのテーブルに含まれていることに注意してください。 また、TPH マッピングとは異なり、列と行の組み合わせは、それがモデルで使われていないテーブルには含まれません。 以下では、これらの特性がクエリとストレージにとってどのように重要であるかを説明します。

キーの生成

継承マッピング戦略の選択は、主キー値を生成して管理する方法に影響します。 TPH では、各エンティティ インスタンスが 1 つのテーブル内の 1 つの行で表されるため、キーは簡単です。 任意の種類のキー値生成を使用でき、追加の制約は必要ありません。

TPT 戦略の場合は、階層の基本データ型にマップされている行がテーブルに常に存在します。 この行では任意の種類のキー生成を使用でき、他のテーブルのキーは、外部キー制約を使ってこのテーブルにリンクされます。

TPC では、処理が少し複雑になります。 まず、EF Core では、エンティティの型が異なる場合でも、階層内のすべてのエンティティに一意のキー値を持っている必要があることを理解しておくことが重要です。 たとえば、このサンプル モデルを使用すると、Dog は Cat と同じ ID キー値を持つことはできません。 第 2 に、TPT とは異なり、キー値を保持し、生成できる単一の場所となる共通テーブルはありません。 これは、単純な Identity 列を使用できないことを意味します。

シーケンスをサポートするデータベースの場合、各テーブルの既定の制約で参照される 1 つのシーケンスを使って、キー値を生成できます。 これは、上で示した TPC テーブルで使われている戦略であり、各テーブルには次のものが含まれます。

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence は、EF Core によって作成されたデータベース シーケンスです。 SQL Server 用の EF Core データベース プロバイダーを使うと、この戦略が TPC 階層に対して既定で使われます。 シーケンスをサポートする他のデータベースのデータベース プロバイダーも、同様の既定値を使う必要があります。 Hi-Lo パターンなど、シーケンスを使う他のキー生成戦略も TPC で使用できます。

標準の ID 列は TPC では機能しませんが、各テーブルに対して生成される値が競合しないよう、各テーブルが適切なシードとインクリメントで構成されている場合は、ID 列を使用できます。 次に例を示します。

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

重要

この戦略を使用すると、階層内の型の合計数を事前に把握しておく必要があるため、後で派生型を追加することが困難になります。

SQLite はシーケンスまたは ID のシードとインクリメントをサポートしていないため、TPC 戦略で SQLite を使うときは、整数キー値の生成はサポートされません。 ただし、クライアント側の生成やグローバルに一意のキー (GUID など) は、SQLite を含むすべてのデータベースでサポートされます。

外部キー制約

TPC マッピング戦略では、非正規化された SQL スキーマが作成されます。これは、一部のデータベース純粋主義者がそれに反対する理由の 1 つです。 たとえば、外部キー列 FavoriteAnimalId について考えます。 この列の値は、何かの動物の主キー値と一致する必要があります。 これは、TPH または TPT を使うときは、単純な FK 制約でデータベースに適用できます。 次に例を示します。

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

しかし、TPC を使うと、特定の動物の主キーは、その動物の具象型に対応するテーブルに格納されます。 たとえば、猫の主キーは Cats.Id 列に格納され、犬の主キーは Dogs.Id 列に格納されます。 これは、このリレーションシップに対しては FK 制約を作成できないことを意味します。

実際には、アプリケーションで無効なデータの挿入を試みない限り、これは問題ではありません。 たとえば、すべてのデータが EF Core によって挿入され、ナビゲーションを使ってエンティティを関連付ける場合、常に FK 列に有効な PK 値が含まれることが保証されます。

まとめとガイダンス

つまり、TPH は、通常、ほとんどのアプリケーションで問題なく動作し、幅広いシナリオに対して適切な既定値であるため、必要でない限り TPC の複雑さを追加しないでください。 具体的には、多くの型のエンティティのクエリを実行することが多いコードの場合は (基本データ型に対するクエリの記述など)、TPC より TPH が適しています。

つまり、TPCは、コードが主に単一のリーフ型のエンティティを照会するものであり、TPH と比較してベンチマークが向上する場合に使用するのに適したマッピング戦略でもあります。

TPT は、外部要因によって TPT を使うしかない場合にのみ使ってください。