EF Core 7.0 の新機能

EF Core 7.0 (EF7) は 2022 年 11 月にリリースされました。

ヒント

サンプルは、GitHub からサンプル コードをダウンロードすることにより、実行してデバッグできます。 各セクションは、そのセクションに固有のソース コードにリンクします。

EF7 は .NET 6 を対象としているため、.NET 6 (LTS) または .NET 7 で使用できます。

サンプル モデル

以下の例の多くでは、ブログ、投稿、タグ、作成者を含む簡単なモデルが使われています。

public class Blog
{
    public Blog(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Author
{
    public Author(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

一部の例では、異なるサンプルにおいて異なる方法でマップされる集約型も使われています。 連絡先には 1 つの集約型があります。

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

投稿メタデータの 2 番目の集約型:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

ヒント

サンプル モデルは BlogsContext.cs にあります。

JSON 列

ほとんどのリレーショナル データベースでは、JSON ドキュメントを含む列がサポートされています。 これらの列の JSON は、クエリを使って詳しく調べることができます。 これにより、たとえば、ドキュメントの要素によるフィルター処理や並べ替え、およびドキュメントから結果への要素のプロジェクションが可能になります。 JSON 列を使うと、リレーショナル データベースにドキュメント データベースの特性の一部を持たせて、2 つの間に便利なハイブリッドを作成できます。

EF7 には、プロバイダーに依存しない JSON 列のサポートと、SQL Server 用の実装が含まれています。 このサポートにより、.NET 型から構築された集約を JSON ドキュメントにマッピングできるようになります。 通常の LINQ クエリを集約に対して使用でき、JSON を掘り下げるために必要な適切なクエリ コンストラクトに変換されます。 EF7 では、JSON ドキュメントに対する変更の更新と保存もサポートされています。

Note

JSON に対する SQLite のサポートは、EF7 の後で計画されています。 PostgreSQL と Pomelo MySQL のプロバイダーには、JSON 列のサポートが既に含まれています。 それらのプロバイダーの作成者と協力して、すべてのプロバイダーで JSON のサポートを揃えるようにします。

JSON 列へのマッピング

EF Core では、集約型は OwnsOneOwnsMany を使って定義されます。 たとえば、連絡先情報の格納に使われるサンプル モデルの集約型について考えます。

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

これは、たとえば、作成者の連絡先の詳細を格納するために、"所有者" エンティティ型で使用できます。

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

集約型は、OwnsOne を使って OnModelCreating で構成されます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

ヒント

ここで示すコードは 、JsonColumnsSample.cs のものです。

既定では、リレーショナル データベースのプロバイダーは、このような集約型をそれが所有するエンティティ型と同じテーブルにマップします。 つまり、ContactDetails および Address クラスの各プロパティは、Authors テーブルの列にマップされます。

連絡先の詳細を含む保存された作成者の一部は次のようになります。

作成者

Id 名前 Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Main St Camberwick Green CW1 5ZH 英国 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH 英国 01632 12346
3 Daniel Roth 3 Main St Camberwick Green CW1 5ZH 英国 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH イギリス 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH 英国 01632 12349

必要に応じて、集約を構成する各エンティティ型を、代わりにそれ自体のテーブルにマップできます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

その後は、3 つのテーブルに同じデータが格納されます。

作成者

Id 名前
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

連絡先

AuthorId Phone
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

アドレス

ContactDetailsAuthorId Street 市区町村 Postcode
1 1 Main St Camberwick Green CW1 5ZH 英国
2 2 Main St Chigley CW1 5ZH 英国
3 3 Main St Camberwick Green CW1 5ZH 英国
4 15a Main St Chigley CW1 5ZH イギリス
5 4 Main St Chigley CW1 5ZH 英国

ここで、興味深いことがあります。 EF7 では、ContactDetails 集約型を JSON 列にマップできます。 そのために必要なのは、集約型を構成するときに ToJson() を呼び出すことだけです。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

これで、Authors テーブルには、各作成者の JSON ドキュメントで設定された ContactDetails の JSON 列が含まれるようになります。

作成者

Id 名前 Contact
1 Maddy Montaquila {
  "Phone":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"1 Main St"
  }
}
2 Jeremy Likness {
  "Phone":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"2 Main St"
  }
}
3 Daniel Roth {
  "Phone":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"3 Main St"
  }
}
4 Arthur Vickers {
  "Phone":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"15a Main St"
  }
}
5 Brice Lambson {
  "Phone":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"4 Main St"
  }
}

ヒント

集計のこのような使用は、Azure Cosmos DB 用の EF Core プロバイダーを使うときに JSON ドキュメントがマップされる方法とよく似ています。 JSON 列により、ドキュメント データベースに対して EF Core を使用する機能が、リレーショナル データベースに埋め込まれたドキュメントに提供されます。

上で示した JSON ドキュメントは非常に単純ですが、さらに複雑なドキュメント構造でもこのマッピング機能を使用できます。 たとえば、投稿に関するメタデータを表すために使われる、サンプル モデルの別の集約型について考えます。

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

この集約型には、複数の入れ子になった型とコレクションが含まれます。 この集約型をマップするには、OwnsOneOwnsMany の呼び出しが使われます。

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

ヒント

ToJson は、集約全体を JSON ドキュメントにマップするために、集約ルートでのみ必要です。

このマッピングにより、EF7 は次のような複雑な JSON ドキュメントを作成してクエリを実行できます。

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Note

空間型を JSON に直接マッピングすることは、まだサポートされていません。 上記のドキュメントでは、回避策として double 値を使っています。 関心がある場合は、「JSON 列で空間型をサポートする」に投票してください。

Note

プリミティブ型のコレクションを JSON にマッピングすることは、まだサポートされていません。 上記のドキュメントでは、値コンバーターを使って、コレクションをコンマ区切りの文字列に変換しています。 関心がある場合は、「Json: プリミティブ型のコレクションのサポートを追加する」に投票してください。

Note

所有型から JSON へのマッピングは、TPT または TPC の継承との組み合わせではまだサポートされていません。 関心がある場合は、「TPT/TPC の継承のマッピングで JSON プロパティをサポートする」に投票してください。

JSON 列のクエリ

JSON 列のクエリは、EF Core での他の集約型のクエリと同じように機能します。 つまり、LINQ を使用するだけです。 次に例をいくつか示します。

Chigley に住むすべての作成者のクエリ:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

SQL Server を使っていると、このクエリでは次の SQL が生成されます。

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

JSON ドキュメント内の Address から City を取得するために JSON_VALUE を使っていることに注意してください。

Select を使って、JSON ドキュメントから要素を抽出して投影できます。

var postcodesInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .Select(author => author.Contact.Address.Postcode)
    .ToListAsync();

このクエリでは、次の SQL が生成されます。

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

フィルターとプロジェクションの処理を追加し、JSON ドキュメントの電話番号での並べ替えも行う例を次に示します。

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

このクエリでは、次の SQL が生成されます。

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

また、JSON ドキュメントにコレクションが含まれている場合は、結果にこれらを投影できます。

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

このクエリでは、次の SQL が生成されます。

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Note

JSON コレクションを含むさらに複雑なクエリでは、jsonpath のサポートが必要です。 関心がある場合は、「jsonpath のクエリをサポートする」に投票してください。

ヒント

JSON ドキュメントでのクエリのパフォーマンスを向上させるには、インデックスの作成を検討してください。 たとえば、SQL Server を使っているときは、「Json データのインデックスを作成する」をご覧ください。

JSON 列の更新

SaveChangesSaveChangesAsync は、JSON 列を更新する通常の方法で動作します。 広範な変更の場合は、ドキュメント全体が更新されます。 たとえば、ある作成者の Contact ドキュメントの大部分を置き換えます。

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

この場合、新しいドキュメント全体がパラメーターとして渡されます。

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

その後、それが UPDATE SQL で使われます。

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

一方、サブドキュメントのみが変更される場合は、EF Core は JSON_MODIFY コマンドを使ってサブドキュメントのみを更新します。 たとえば、Contact ドキュメント内の Address を変更します。

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

次のパラメーターが生成されます。

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

これは、JSON_MODIFY の呼び出しを通じて UPDATE で使われます。

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

最後に、1 つのプロパティのみが変更される場合は、EF Core はやはり "JSON_MODIFY" コマンドを使いますが、この場合は変更されるプロパティ値のみがパッチされます。 次に例を示します。

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

次のパラメーターが生成されます。

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

これがやはり JSON_MODIFY で使われます。

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate と ExecuteDelete (一括更新)

既定では、EF Core はエンティティへの変更を追跡し、SaveChanges メソッドのいずれかが呼び出されると、データベースに更新を送信します。 変更は、実際に変更されるプロパティとリレーションシップに対してのみ送信されます。 また、追跡対象のエンティティと、データベースに送信される変更との同期が維持されます。 このメカニズムは、汎用の挿入、更新、削除をデータベースに送信する効率的で便利な方法です。 データベース ラウンドトリップの数を減らすため、これらの変更のバッチ処理も行われます。

ただし、変更トラッカーを使わずに、データベースで更新または削除コマンドを実行すると便利な場合があります。 EF7 では、新しい ExecuteUpdate および ExecuteDelete メソッドでこれを行うことができます。 これらのメソッドは LINQ クエリに適用され、そのクエリの結果に基づいてデータベース内のエンティティを更新または削除します。 多くのエンティティを 1 つのコマンドで更新でき、エンティティはメモリに読み込まれません。そのため、更新と削除がより効率的になる可能性があります。

ただし、次の点に注意してください。

  • 行う特定の変更を、明示的に指定する必要があります。EF Core によって自動的に検出されることはありません。
  • 追跡対象のエンティティの同期は維持されません。
  • データベースの制約に違反しないように、追加のコマンドを正しい順序で送信することが必要な場合があります。 たとえば、プリンシパルを削除する前に、依存エンティティを削除する必要があります。

これは、ExecuteUpdate および ExecuteDelete メソッドは、既存の SaveChanges のメカニズムを、置き換えるのではなく、補完することを意味します。

基本的な ExecuteDelete の例

ヒント

ここで示すコードは ExecuteDeleteSample.cs のものです。

DbSetExecuteDelete または ExecuteDeleteAsync を呼び出すと、その DbSet のすべてのエンティティがデータベースから直ちに削除されます。 たとえば、すべての Tag エンティティを削除するには、次のようにします。

await context.Tags.ExecuteDeleteAsync();

SQL Server を使っていると、これにより次の SQL が実行されます。

DELETE FROM [t]
FROM [Tags] AS [t]

さらに興味深いことに、クエリにフィルターを含めることができます。 次に例を示します。

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

これにより、次の SQL が実行されます。

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

クエリでは、他の型へのナビゲーションなど、さらに複雑なフィルターを使うこともできます。 たとえば、古いブログ投稿からのみタグを削除するには、次のようにします。

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

これにより、以下が実行されます。

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

基本的な ExecuteUpdate の例

ヒント

ここで示すコードは ExecuteUpdateSample.cs のものです。

ExecuteUpdateExecuteUpdateAsync の動作方法は、ExecuteDelete メソッドとよく似ています。 主な違いは、更新するには、"どの" プロパティを "どのように" 更新するかを知る必要があるということです。 これは、SetProperty を 1 回以上呼び出すことで実現されます。 たとえば、すべてのブログの Name を更新するには、次のようにします。

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

SetProperty の最初のパラメーターでは、更新するプロパティを指定します (この場合は Blog.Name)。 2 番目のパラメーターでは、新しい値の計算方法を指定します。この場合は、既存の値を取得して "*Featured!*" を追加します。 結果の SQL は次のとおりです。

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

ExecuteDelete と同様に、クエリを使って、更新されるエンティティをフィルター処理できます。 さらに、SetProperty の複数の呼び出しを使って、対象のエンティティの複数のプロパティを更新できます。 たとえば、2022 年より前に公開されたすべての投稿の TitleContent を更新するには、次のようにします。

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

この場合、生成される SQL はもう少し複雑になります。

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

最後に、やはり ExecuteDelete と同様に、フィルターで他のテーブルを参照できます。 たとえば、古い投稿のすべてのタグを更新するには、次のようにします。

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

以下が生成されます。

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

ExecuteUpdateExecuteDelete に関する詳細とコード サンプルについては、「ExecuteUpdate と ExecuteDelete」を参照してください。

継承と複数のテーブル

ExecuteUpdateExecuteDelete は、1 つのテーブルに対してのみ実行できます。 異なる継承マッピング戦略を使っている場合、これにより影響があります。 一般に、TPH マッピング戦略を使っている場合は、変更するテーブルが 1 つだけなので、問題はありません。 たとえば、すべての FeaturedPost エンティティを削除する場合、次のようになります。

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

TPH マッピングを使っているときは、次の SQL が生成されます。

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

また、このケースでは、TPC マッピング戦略を使っている場合でも、やはり 1 つのテーブルの変更だけが必要なので、問題はありません。

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

一方、TPT マッピング戦略を使っているときにこれを試みると、2 つの異なるテーブルから行を削除する必要があるため、失敗します。

クエリにフィルターを追加すると、多くの場合、TPC と TPT どちらの戦略でも操作が失敗します。 これは、複数のテーブルから行を削除することが必要な場合があるためです。 たとえば、このクエリは

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

TPH を使っていると、次の SQL が生成されます。

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

一方、TPC または TPT を使っていると失敗します。

ヒント

Issue #10879 では、これらのシナリオで複数のコマンドを自動的に送信するためのサポートの追加が追跡されています。 この実装が必要な場合は、この issue に投票してください。

ExecuteDelete とリレーションシップ

上で説明したように、リレーションシップのプリンシパルを削除する前に、依存エンティティの削除または更新が必要な場合があります。 たとえば、各 Post は、その関連付けられている Author の依存エンティティです。 これは、投稿でまだ参照されている作成者は削除できないことを意味します。行うと、データベースの外部キー制約に違反します。 たとえば、次の操作を試みます。

await context.Authors.ExecuteDeleteAsync();

すると、SQL Server で次の例外が発生します。

Microsoft.Data.SqlClient.SqlException (0x80131904): DELETE ステップは REFERENCE 制約 "FK_Posts_Authors_AuthorId" と競合しています。 競合が発生したのは、データベース "TphBlogsContext"、テーブル "dbo.Posts"、列 'AuthorId' です。 ステートメントは終了されました。

これを解決するには、最初に投稿を削除するか、AuthorId 外部キー プロパティを null に設定することで各投稿とその作成者との間のリレーションシップを切断する必要があります。 たとえば、削除オプションを使うと次のようになります。

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

ヒント

TagWith を使うと、通常のクエリにタグを付けるのと同じ方法で、ExecuteDelete または ExecuteUpdate にタグ付けることができます。

これにより、2 つの個別のコマンドが作成されます。1 つ目で依存エンティティを削除します。

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

2 つ目でプリンシパルを削除します。

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

重要

既定では、1 つのトランザクションに複数の ExecuteDelete および ExecuteUpdate コマンドは含まれません。 ただし、DbContext トランザクション API は、通常の方法でこれらのコマンドをトランザクションにラップするために使用できます。

ヒント

これらのコマンドを 1 回のラウンドトリップで送信するには、Issue #10879 に依存します。 この実装が必要な場合は、この issue に投票してください。

データベースでカスケード削除を構成すると、このような場合に非常に役立ちます。 このモデルでは、BlogPost の間のリレーションシップは必須であり、EF Core は規則によってカスケード削除を構成します。 つまり、ブログがデータベースから削除されると、それに依存する投稿もすべて削除されます。 したがって、すべてのブログと投稿を削除するには、ブログのみを削除する必要があります。

await context.Blogs.ExecuteDeleteAsync();

結果の SQL は次のようになります。

DELETE FROM [b]
FROM [Blogs] AS [b]

これにより、ブログを削除すると、構成されたカスケード削除によって、関連するすべての投稿も削除されます。

SaveChanges の高速化

EF7 では、SaveChangesSaveChangesAsync のパフォーマンスが大幅に向上しています。 一部のシナリオでは、変更の保存が EF Core 6.0 より最大で 4 倍速くなります。

これらの改善の大部分は、次の理由によるものです。

  • データベースへのラウンドトリップ実行回数の減少
  • より高速な SQL の生成

これらの改善の例をいくつか次に示します。

Note

これらの変更について詳しくは、.NET ブログの「Entity Framework Core 7 Preview 6 のお知らせ: パフォーマンス エディション」をご覧ください。

ヒント

ここで示すコードは SaveChangesPerformanceSample.cs のものです。

必要のないトランザクションが除去される

すべての最新のリレーショナル データベースでは、(ほとんどの) 単一 SQL ステートメントについてトランザクション性が保証されています。 つまり、エラーが発生しても、ステートメントの一部だけが完了することはありません。 このような場合、EF7 は明示的なトランザクションの開始を回避します。

たとえば、次のような SaveChanges の呼び出しのログを確認してください。

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

EF Core 6.0 では、INSERT コマンドは、トランザクションを開始してからコミットするコマンドによってラップされています。

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 では、このようなトランザクションは必要ないことが検出され、次の呼び出しが削除されます。

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

これにより、2 つのデータベース ラウンドトリップが削除されます。これは、特にデータベースの呼び出しの待ち時間が長い場合、全体的なパフォーマンスに大きな違いをもたらす可能性があります。 一般的な運用システムでは、データベースはアプリケーションと同じコンピューターに併置されません。 つまり、待ち時間が比較的長くなることが多いため、この最適化は実際の運用システムで特に有効になります。

簡単な ID 挿入のための SQL の改善

上のケースでは、IDENTITY キー列を含む 1 つの行が挿入され、他にデータベースで生成される値はありません。 EF7 では、OUTPUT INSERTED を使うことによってこの場合の SQL が簡素化されます。 この簡素化は他の多くのケースでは有効ではありませんが、この種の単一行挿入は多くのアプリケーションで非常に一般的であるため、改善することはやはり重要です。

複数行の挿入

EF Core 6.0 では、複数の行を挿入する既定の方法は、トリガーを含むテーブルに関する SQL Server のサポートでの制限によって決められていました。 テーブルでトリガーを使用する少数のユーザーに対しても、既定のエクスペリエンスが機能することを確認したいと考えました。 これは、単純な OUTPUT 句は、SQL Server ではトリガーがあると機能しないため、使用できなかったことを意味します。 代わりに、複数のエンティティを挿入するときは、EF Core 6.0 によってかなり複雑な SQL が生成されました。 たとえば、次のような SaveChanges の呼び出しです。

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

EF Core 6.0 を使用する SQL Server に対して実行すると、結果は次のアクションになります。

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

重要

これは複雑ですが、それでも、このような複数の挿入をバッチ処理する方が、挿入ごとに 1 つのコマンドを送信するよりかなり高速です。

EF7 では、テーブルにトリガーが含まれている場合はやはりこの SQL になりますが、一般的なケースでは、若干複雑ではあってもはるかに効率的なコマンドが生成されます。

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

MERGE は暗黙的なトランザクションによって保護される単一のステートメントであるため、単一の挿入の場合と同様に、トランザクションはなくなります。 また、一時テーブルは使われなくなり、OUTPUT 句では生成された ID がクライアントに直接送り返されるようになります。 これは、アプリケーションとデータベースの間の待ち時間などの環境要因によっては、EF Core 6.0 より 4 倍高速になる可能性があります。

トリガー

テーブルにトリガーがある場合、上のコードで SaveChanges を呼び出すと例外がスローされます。

ハンドルされていない例外です。 Microsoft.EntityFrameworkCore.DbUpdateException:
ターゲット テーブルにデータベース トリガーがあるため、変更を保存できませんでした。 それに応じてエンティティの種類を構成してください。詳細については、https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers をご覧ください。
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
DML ステートメントに INTO 句が指定されていない OUTPUT 句が含まれている場合、このステートメントの対象のテーブル 'BlogsWithTriggers' に有効なトリガーを指定することはできません。

次のコードを使って、テーブルにトリガーがあることを EF Core に通知できます。

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

それにより、このテーブルに挿入および更新コマンドを送信するときは、EF7 は EF Core 6.0 SQL に戻ります。

トリガーを使用してマップされたすべてのテーブルを自動的に構成する規則など、詳細については、EF7 の破壊的変更に関するドキュメントの「トリガーを含む SQL Server テーブルに特別な EF Core 構成が必要になりました」を参照してください。

グラフを挿入するためのラウンドトリップ数の削減

新しいプリンシパル エンティティと、新しいプリンシパルを参照する外部キーを持つ新しい依存エンティティを含む、エンティティのグラフの挿入について考えます。 次に例を示します。

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

プリンシパルの主キーがデータベースによって生成される場合、依存エンティティの外部キーに設定する値は、プリンシパルが挿入されるまでわかりません。 EF Core では、これのための 2 つのラウンドトリップが生成されます。1 つはプリンシパルを挿入して新しい主キーを取得するためのもので、もう 1 つは外部キーの値が設定された依存エンティティを挿入するためのものです。 また、これには 2 つのステートメントがあるため、トランザクションが必要です。つまり、全部で 4 回のラウンドトリップがあります。

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

ただし、場合によっては、プリンシパルが挿入される前に主キーの値がわかっていることがあります。 これには次のものが含まれます

  • 自動的に生成されないキー値
  • クライアントで生成されるキー値 (Guid キーなど)
  • サーバーにおいてバッチで生成されるキー値 (hi-lo 値ジェネレーターを使っているときなど)

EF7 では、これらのケースは 1 回のラウンドトリップに最適化されるようになりました。 たとえば、上の SQL Server のケースでは、hi-lo 生成戦略を使うように主キー Blog.Id を構成できます。

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

上の SaveChanges の呼び出しは、挿入の 1 つのラウンドトリップに最適化されるようになります。

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

この場合でもトランザクションは必要であることに注意してください。 これは、挿入が 2 つの異なるテーブルに対して行われているためです。

EF Core 6.0 では複数のバッチが作成される他のケースでも、EF7 では 1 つのバッチが使われます。 たとえば、同じテーブルで行の削除と挿入を行う場合などです。

SaveChanges の値

ここのいくつかの例で示されているように、データベースへの結果の保存は複雑な処理になることがあります。 ここで EF Core のようなものを使うと、その価値が実際にわかります。 EF Core:

  • 複数の挿入、更新、削除コマンドをまとめてバッチ処理し、ラウンドトリップを減らします
  • 明示的なトランザクションが必要かどうかを明らかにします
  • データベースの制約に違反しないように、エンティティを挿入、更新、削除する順序を決定します
  • データベースで生成された値が効率的に返され、エンティティに反映されるようにします
  • 主キーに対して生成された値を使って、外部キーの値を自動的に設定します
  • コンカレンシーの競合を検出します

さらに、これらのケースの多くでは、データベース システムが異なると、異なる SQL が必要になります。 EF Core データベース プロバイダーは EF Core と連携して、ケースごとに正しく効率的なコマンドが送信されるようにします。

Table-Per-Concrete-Type (TPC) 継承マッピング

EF Core の既定では、.NET 型の継承階層が 1 つのデータベース テーブルにマップされます。 これは、Table-Per-Hierarchy (TPH) マッピング戦略と呼ばれます。 EF Core 5.0 では、.NET 型ごとに異なるデータベース テーブルへのマッピングをサポートする、Table-Per-Type (TPT) 戦略が導入されました。 EF7 では、Table-Per-Concrete-Type (TPC) 戦略が導入されています。 TPC も .NET 型を異なるテーブルにマップしますが、TPT 戦略にあった一般的なパフォーマンスの問題に対処する方法で行われます。

ヒント

ここで示すコードは TpcInheritanceSample.cs のものです。

ヒント

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

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 マッピングとは異なり、列と行の組み合わせは、それがモデルで使われていないテーブルには含まれません。 以下では、これらの特性がクエリとストレージにとってどのように重要であるかを説明します。

TPC 継承の構成

EF Core で階層をマッピングするときは、継承階層内のすべての型が、モデルに明示的に含まれている必要があります。 これは、すべての型の DbContextDbSet プロパティを作成することによって行うことができます。

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

または、OnModelCreatingEntity メソッドを使います。

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

重要

これは、マップされた基本データ型の派生型が同じアセンブリに含まれている場合は自動的に検出される、従来の EF6 の動作とは異なります。

これは既定の戦略であるため、階層を TPH としてマップするために、他に何もする必要はありません。 一方、EF7 以降では、階層の基本データ型で UseTphMappingStrategy を呼び出すことにより、TPH を明示的にすることができます。

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

代わりに TPT を使うには、これを UseTptMappingStrategy に変更します。

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

同様に、TPC を構成するには UseTpcMappingStrategy が使われます。

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

いずれの場合も、各型に使われるテーブル名は、DbContext での DbSet のプロパティ名から取得されるか、ToTable ビルダー メソッドまたは [Table] 属性を使って構成することができます

TPC クエリのパフォーマンス

クエリに関しては、TPC 戦略では特定のエンティティ インスタンスの情報が常に 1 つのテーブルに格納されることが保証されるため、TPT より向上しています。 つまり、TPC 戦略は、マップされた階層が大きく、多くの具象 (通常はリーフ) 型があり、それぞれが多数のプロパティを持ち、ほとんどのクエリで型の小さいサブセットだけが使われる場合に役立ちます。

3 つの簡単な LINQ クエリに対して生成される SQL を使って、TPH や TPT より TPC の動作が優れている場合を確認できます。 これらのクエリは次のとおりです。

  1. 階層内のすべての型のエンティティを返すクエリ:

    context.Animals.ToList();
    
  2. 階層内の型のサブセットからエンティティを返すクエリ:

    context.Pets.ToList();
    
  3. 階層内の単一のリーフ型のエンティティのみを返すクエリ:

    context.Cats.ToList();
    

TPH のクエリ

TPH を使うと、3 つのクエリはすべて 1 つのテーブルのみのクエリを実行しますが、識別子列のフィルターは異なります。

  1. 階層内のすべての型のエンティティを返す TPH SQL:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. 階層内の型のサブセットからエンティティを返す TPH SQL:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. 階層内の単一のリーフ型からのみエンティティを返す TPH SQL:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

これらのクエリはすべて、良好なパフォーマンスを示すはずです (特に、識別子列に適切なデータベース インデックスがある場合)。

TPT のクエリ

TPT を使うと、特定の具象型のデータが多くのテーブルに分割されるため、これらのクエリのすべてで複数のテーブルを結合する必要があります。

  1. 階層内のすべての型のエンティティを返す TPT SQL:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. 階層内の型のサブセットからエンティティを返す TPT SQL:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. 階層内の単一のリーフ型からのみエンティティを返す TPT SQL:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Note

EF Core では、"識別子合成" を使って、データの取得元のテーブル、したがって使用する正しい型が特定されます。 これが動作するのは、LEFT JOIN が依存 ID 列 ("サブテーブル") に対して正しい型ではない null を返すためです。 したがって、犬の場合、[d].[Id] は null 以外になり、他のすべての (具象) ID は null になります。

これらのクエリすべてで、テーブル結合のためにパフォーマンスの問題が発生する可能性があります。 このため、TPT はクエリのパフォーマンスに関しては適切な選択ではありません。

TPC のクエリ

TPC は、クエリを実行する必要があるテーブルの数が減るため、これらすべてのクエリについて TPT より向上します。 さらに、各テーブルの結果は UNION ALL を使って結合されますが、行間の照合や行の重複除去を実行する必要がないため、テーブル結合よりかなり高速になる可能性があります。

  1. 階層内のすべての型のエンティティを返す TPC SQL:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. 階層内の型のサブセットからエンティティを返す TPC SQL:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. 階層内の単一のリーフ型からのみエンティティを返す TPC SQL:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

これらのクエリすべてで TPC は TPT より優れていますが、それでも、複数の型のインスタンスを返すときは、TPH クエリの方が優れています。 その理由の 1 つは、TPH が EF Core で使われる既定の戦略であることです。

クエリ #3 の SQL で示されているように、TPC が実際に優れているのは、単一のリーフ型エンティティのクエリを実行する場合です。 そのクエリでは 1 つのテーブルのみが使われ、フィルター処理は必要ありません。

TPC の挿入と更新

TPC は、新しいエンティティを保存するときのパフォーマンスも優れています。これは、単一のテーブルに 1 つの行のみを挿入する必要があるためです。 これは TPH にも当てはまります。 TPT では、多くのテーブルに行を挿入する必要があり、パフォーマンスが低下します。

通常、更新にも同じことが当てはまりますが、このケースでは、更新されるすべての列が同じテーブル内にある場合は、TPT であっても、大きく違わない可能性があります。

領域に関する考慮事項

あまり使われない多くのプロパティを持つサブタイプが多数存在する場合、TPT と TPC のどちらも、記憶域の使用量を TPH より減らすことができます。 これは、TPH では、テーブル内のすべての行で、これらの未使用のプロパティごとに NULL を格納する必要があるためです。 実際には、これが問題になることはあまりありませんが、これらの特性を持つ大量のデータを格納する場合は検討する価値があります。

ヒント

データベース システムで "スパース列" がサポートされている場合は (たとえば、SQL Server)、ほとんど設定されない 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 値が含まれることが保証されます。

まとめとガイダンス

まとめると、TPC は、コードが主に単一のリーフ型のエンティティのクエリを実行するときに使うのに適したマッピング戦略です。 これは、必要な記憶域が小さく、インデックスが必要になる場合がある識別子列がないためです。 挿入と更新も効率的です。

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

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

カスタム リバース エンジニアリング テンプレート

データベースから EF モデルをリバース エンジニアリングするときに、スキャフォールディングされたコードをカスタマイズできるようになりました。 プロジェクトに既定のテンプレートを追加することで作業を始めます。

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

その後、テンプレートはカスタマイズでき、dotnet ef dbcontext scaffoldScaffold-DbContext で自動的に使われます。

詳しくは、「カスタム リバース エンジニアリング テンプレート」をご覧ください。

ヒント

EF チームは、.NET Data Community Standup のエピソードでリバース エンジニアリング テンプレートの詳細について実演および説明しました。 Community Standup のすべてのエピソードと同様に、YouTube で T4 テンプレートのエピソードを見ることができます。

モデル構築の規則

EF Core では、メタデータ "モデル" を使って、アプリケーションのエンティティ型が基になるデータベースにどのようにマップされるかを記述します。 このモデルは、約 60 個の "規則" のセットを使用して構築されます。 規則を使って構築されたモデルは、マッピング属性 ("データ注釈" とも呼ばれます)OnModelCreating での DbModelBuilder API の呼び出しを使って、カスタマイズすることができます。

EF7 以降では、アプリケーションでこれらの規則のいずれかを削除または置換したり、新しい規則を追加したりできるようになりました。 モデル構築規則は、モデルの構成を制御するための強力な方法ですが、複雑で、正しく使用するのが難しい場合があります。 多くの場合、既存の規則の前のモデル構成を代わりに使って、プロパティと型の共通構成を簡単に指定できます。

DbContext によって使われる規則に対する変更は、DbContext.ConfigureConventions メソッドをオーバーライドすることによって行います。 次に例を示します。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

ヒント

すべての組み込みモデル構築規則を調べるには、IConvention インターフェイスを実装しているすべてのクラスを探します。

ヒント

ここで示すコードは ModelBuildingConventionsSample.cs のものです。

既存の規則の削除

組み込み規則の 1 つがアプリケーションに適していないことがあり、その場合は削除できます。

例: 外部キー列のインデックスを作成しない

通常、外部キー (FK) 列のインデックスを作成することは理にかなっており、そのため次の規則が組み込まれています: ForeignKeyIndexConventionBlogAuthor に対するリレーションシップを持つ Post エンティティ型のモデル デバッグ ビューを見ると、BlogId FK 用と AuthorId FK 用に 2 つのインデックスが作成されていることがわかります。

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

ただし、インデックスにはオーバーヘッドがあり、こちらで質問されているように、すべての FK 列にインデックスを作成するのが常に適切であるとは限りません。 これを実現するには、モデルを構築するときに ForeignKeyIndexConvention を削除できます。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

ここで、Post のモデルのデバッグ ビューを見ると、FK のインデックスが作成されていないことがわかります。

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

必要に応じて、次のいずれかで外部キー列にインデックスを明示的に作成できます。IndexAttribute を使います。

[Index("BlogId")]
public class Post
{
    // ...
}

または、OnModelCreating の構成を使います。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

Post エンティティ型をもう一度見ると、BlogId インデックスは含まれますが、AuthorId インデックスは含まれません。

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

ヒント

モデルで構成にマッピング属性 (データ注釈とも呼ばれます) が使われていない場合は、末尾が AttributeConvention のすべての規則を安全に削除して、モデルの構築を高速化できます。

新しい規則の追加

既存の規則を削除することは出発点ですが、まったく新しいモデル構築規則を追加する方はどうでしょうか。 EF7 ではこれもサポートされています。

例: 識別子プロパティの長さを制限する

Table-Per-Hierarchy 継承マッピング戦略では、特定の行で表される型を指定するために識別子列が必要です。 既定では、EF は識別子に無制限の文字列列を使用します。これにより、どのような長さの識別子でも機能することが保証されます。 ただし、識別子文字列の最大長を制限すると、記憶域とクエリをいっそう効率的にできます。 それを行う新しい規則を作成しましょう。

EF Core のモデル構築規則は、構築中のモデルに対して行われた変更に基づいてトリガーされます。 これにより、明示的な構成が行われ、マッピング属性が適用され、その他の規則が実行されるため、モデルは最新の状態に維持されます。 これに加わるため、すべての規則で、規則がトリガーされるタイミングを決定するインターフェイスを 1 つ以上実装します。 たとえば、IEntityTypeAddedConvention 実装する規則は、新しいエンティティ型がモデルに追加されるたびにトリガーされます。 同様に、IForeignKeyAddedConventionIKeyAddedConvention の両方を実装する規則は、キーまたは外部キーがモデルに追加されるたびにトリガーされます。

ある時点でモデルに対して行われた構成は、後で変更または削除される可能性があるため、実装すべきインターフェイスがわかりにくい場合があります。 たとえば、規則によってキーが作成されても、後で別のキーを明示的に構成すると置き換えられる可能性があります。

識別子の長さの規則の最初の実装を試みることで、これをもう少し具体的に見てみましょう。

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

この規則で実装する IEntityTypeBaseTypeChangedConvention は、エンティティ型のマップされた継承階層が変更されるたびにトリガーされることを意味します。 その後、この規則は階層の文字列識別子プロパティを見つけて構成します。

この規則を使うには、ConfigureConventionsAdd を呼び出します。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

ヒント

Add メソッドは、規則のインスタンスを直接追加するのではなく、規則のインスタンスを作成するためのファクトリを受け取ります。 これにより、規則で EF Core の内部サービス プロバイダーから依存関係を使用できます。 この規則には依存関係がないため、サービス プロバイダー パラメーターには、使われないことを示す _ という名前が付けられています。

モデルを構築して、Post エンティティ型を確認すると、これが機能したことがわかります。識別子プロパティは、最大長 24 で構成されるようになりました。

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

しかし、異なる識別子プロパティを明示的に構成した場合はどうなるでしょうか。 次に例を示します。

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

モデルのデバッグ ビューを見ると、識別子の長さが構成されなくなったことがわかります。

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

これは、規則で構成した識別子プロパティが、後でカスタム識別子が追加されたときに削除されたためです。 別のインターフェイスを規則に実装して識別子の変更に対応することで、この修正を試みることはできますが、実装するインターフェイスを見極めるのは簡単ではありません。

幸いなことに、はるかに簡単にこれを実現できる別の方法があります。 多くの場合、最終的なモデルが正しい限り、構築中のモデルの見た目は問題ではありません。 さらに、多くの場合、適用する構成で他の規則をトリガーして対応する必要はありません。 したがって、この規則では IModelFinalizingConvention を実装できます。 モデル最終処理規則は、他のすべてのモデル構築が完了した後で実行されるため、モデルの最終状態にアクセスできます。 モデル最終処理規則は、通常、モデル要素を構成するモデル全体を反復処理します。 したがって、この場合は、モデル内のすべての識別子を見つけて構成します。

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

この新しい規則を使ってモデルを構築した後は、識別子の長さがカスタマイズされていても正しく構成されるようになることがわかります。

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

面白いので、もう一歩進んで、最大長を最も長い識別子の値の長さに設定してみましょう。

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

今度は、識別子列の最大長が 8 になりました。これは、使われている最も長い識別子の値である "Featured" の長さです。

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

ヒント

規則で識別子列のインデックスも作成する必要があるかどうかを疑問に思うかもしれません。 これについては GitHub で話し合われています。 簡単に言ってしまえば、インデックスが役に立つこともありますが、ほとんどの場合は役に立ちません。 そのため、規則で常にそれを行うのではなく、必要に応じて適切なインデックスを作成することをお勧めします。 ただし、これに同意できない場合は、上の規則を簡単に変更してインデックスも作成することができます。

例: すべての文字列プロパティの既定の長さ

最終処理規則を使用できるもう 1 つの例を見てみましょう。今度は、GitHub で要求されているように、"任意の" 文字列プロパティの既定の最大長を設定します。 この規則は、前の例によく似ています。

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

この規則は非常に簡単です。 モデル内ですべての文字列プロパティを検索し、その最大長を 512 に設定します。 デバッグ ビューで Post のプロパティを見ると、すべての文字列プロパティの最大長が 512 になっていることがわかります。

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

しかし、Content プロパティはおそらく 512 文字以上を許可する必要があります。そうでなければ、すべての投稿はかなり短くなります。 これは、このプロパティについてだけ最大長を明示的に構成することで、規則を変更せずに行うことができます。そのためには、マッピング属性を使います。

[MaxLength(4000)]
public string Content { get; set; }

または、OnModelCreating のコードで行います。

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

4000 に明示的に構成された Content プロパティを除き、すべてのプロパティの最大長が 512 になりました。

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

ところで、明示的に構成した最大長が規則でオーバーライドされなかったのはなぜでしょうか。 その答えは、構成の各部分がどのように行われたかを、EF Core が追跡しているからです。 これは、ConfigurationSource 列挙型によって表されます。 構成の種類は次のとおりです。

  • Explicit: そのモデル要素は、OnModelCreating で明示的に構成されました
  • DataAnnotation: そのモデル要素は、CLR 型のマッピング属性 (別名データ注釈) を使って構成されました
  • Convention: そのモデル要素は、モデル構築規則によって構成されました

DataAnnotation または Explicit として行われた構成を、規則がオーバーライドすることはありません。 これは、Builder プロパティから取得される "規則ビルダー" を使って実現されます (たとえば、IConventionPropertyBuilder)。 次に例を示します。

property.Builder.HasMaxLength(512);

規則ビルダーで HasMaxLength を呼び出すと、"マッピング属性または OnModelCreating でまだ構成されていない場合" にのみ、最大長が設定されます。

このようなビルダー メソッドには、2 番目のパラメーター fromDataAnnotation もあります。 規則がマッピング属性に代わって構成を行っている場合、これを true に設定します。 次に例を示します。

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

これにより、ConfigurationSourceDataAnnotation に設定されます。つまり、値は、OnModelCreating での明示的なマッピングによってオーバーライドできるようになりましたが、マッピング属性以外の規則ではオーバーライドできません。

最後に、この例を終了する前に、MaxStringLengthConventionDiscriminatorLengthConvention3 の両方を同時に使うとどうなるでしょう。 答えは、モデルの最終処理規則はそれらが追加された順序で実行されるため、追加された順序によって異なります。 したがって、MaxStringLengthConvention が最後に追加された場合は、それが最後に実行されて、識別子プロパティの最大長を 512 に設定します。 したがって、この場合により適しているのは、識別子プロパティについてだけ既定の最大長をオーバーライドし、他のすべての文字列プロパティは 512 のままにできるように、DiscriminatorLengthConvention3 を最後に追加することです。

既存の規則の置き換え

既存の規則を完全に削除するのではなく、行うことは基本的に同じでも動作が異なる規則に置き換えたいことがあります。 これは、適切にトリガーされるために必要なインターフェイスが既存の規則によって既に実装されるため便利です。

例: プロパティー マッピングをオプトインする

EF Core では、すべてのパブリック読み取り/書き込みプロパティが規則によってマップされます。 これは、エンティティ型の定義方法に対して適していない場合があります。 これを変更するには、プロパティが OnModelCreating で明示的にマップされているか、または Persist という新しい属性でマークされているのでない限りプロパティをマップしない独自の実装に、PropertyDiscoveryConvention を置き換えることができます。

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

新しい規則は次にようになります。

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

ヒント

組み込み規則を置き換えるときは、新しい規則の実装で既存の規則のクラスから継承する必要があります。 一部の規則には、リレーショナルまたはプロバイダー固有の実装があり、その場合、新しい規則の実装では、使われているデータベース プロバイダーの最も具体的な既存の規則クラスから継承する必要があることに注意してください。

その後、ConfigureConventionsReplace メソッドを使って規則を登録されます。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

ヒント

これは、ProviderConventionSetBuilderDependencies 依存関係オブジェクトによって表される依存関係が既存の規則にある場合です。 これらは、GetRequiredService を使って内部サービス プロバイダーから取得されて、規則のコンストラクターに渡されます。

この規則は、指定されたエンティティ型から読み取り可能なすべてのプロパティとフィールドを取得することで機能します。 [Persist] 属性が指定されているメンバーは、次の呼び出しによってマップされます。

entityTypeBuilder.Property(memberInfo);

一方、メンバーが違う方法でマップされていたプロパティの場合は、次を使ってモデルから除外されます。

entityTypeBuilder.Ignore(propertyInfo.Name);

この規則では、[Persist] でマークされているフィールドを (プロパティに加えて) マップできることに注意してください。 つまり、モデルでプライベート フィールドを非表示キーとして使用できます。

たとえば、次のようなエンティティ型を考えます。

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

これらのエンティティ型から構築されたモデルは次のようになります。

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

通常 IsClean はマップされていましたが、[Perist] でマークされていないため (おそらく、清潔さが洗濯物の永続的なプロパティではないため)、マップされないプロパティとして扱われるようになりました。

ヒント

プロパティをマッピングすると、マップされたプロパティをさらに構成するために実行する他の多くの規則がトリガーされるため、この規則をモデルの最終処理規則として実装することはできません。

ストアド プロシージャ マッピング

既定では、EF Core によって、テーブルまたは更新可能なビューを直接操作する挿入、更新、削除のコマンドが生成されます。 EF7 では、これらのコマンドをストアド プロシージャにマッピングするためのサポートが導入されています。

ヒント

EF Core では、ストアド プロシージャによるクエリが常にサポートされています。 EF7 の新しいサポートは、挿入、更新、削除にストアド プロシージャを使用することに関する明示的なものです。

重要

ストアド プロシージャのマッピングのサポートは、ストアド プロシージャが推奨されることを意味するものではありません。

ストアド プロシージャは、InsertUsingStoredProcedureUpdateUsingStoredProcedureDeleteUsingStoredProcedure を使って OnModelCreating でマップされます。 たとえば、Person エンティティ型のストアド プロシージャをマップするには、次のようにします。

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

SQL Server を使っていると、この構成は次のストアド プロシージャにマップされます。

挿入の場合

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

更新の場合

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

削除の場合

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

ヒント

モデル内のすべての型、または特定の型に対するすべての操作に、ストアド プロシージャを使う必要はありません。 たとえば、特定の型に対して DeleteUsingStoredProcedure のみが指定されている場合、EF Core は挿入と更新操作には通常どおり SQL を生成し、削除に対してのみストアド プロシージャを使います。

各メソッドに渡される最初の引数は、ストアド プロシージャ名です。 これは省略できます。その場合、EF Core では、"_Insert"、"_Update"、または "_Delete" が付加されたテーブル名が使われます。 そのため、上の例では、テーブルは "People" という名前なので、機能を変更することなくストアド プロシージャ名を削除できます。

2 番目の引数は、パラメーター、戻り値、結果列など、ストアド プロシージャの入力と出力を構成するために使われるビルダーです。

パラメーター

パラメーターは、ストアド プロシージャの定義と同じ順序でビルダーに追加する必要があります。

Note

パラメーターに名前を付けることはできますが、EF Core は常に、名前付き引数ではなく、位置引数を使ってストアド プロシージャを呼び出します。 名前による呼び出しに関心がある場合は、「呼び出しにパラメーター名を使用するストアド プロシージャ マッピングの構成を許可する」に投票してください。

各パラメーター ビルダー メソッドの最初の引数では、パラメーターがバインドされるモデル内のプロパティを指定します。 これにはラムダ式を使用できます。

storedProcedureBuilder.HasParameter(a => a.Name);

または、文字列を使用できます。これは、シャドウ プロパティをマッピングするときに特に便利です。

storedProcedureBuilder.HasParameter("Name");

パラメーターは、既定では "入力" 用に構成されます。 "出力" または "入力/出力" パラメーターは、入れ子になったビルダーを使って構成できます。 次に例を示します。

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

異なるフレーバーのパラメーター用に、3 つの異なるビルダー メソッドがあります。

  • HasParameter は、特定のプロパティの現在の値にバインドされる通常のパラメーターを指定します。
  • HasOriginalValueParameter は、特定のプロパティの元の値にバインドされるパラメーターを指定します。 元の値は、データベースからクエリが実行されたときに、プロパティに格納されていた値です (既知の場合)。 この値が不明な場合は、代わりに現在の値が使われます。 元の値のパラメーターは、コンカレンシー トークンに役立ちます。
  • HasRowsAffectedParameter は、ストアド プロシージャによって影響を受けた行数を返すために使われるパラメーターを指定します。

ヒント

"更新" と "削除" のストアド プロシージャのキー値には、元の値のパラメーターを使う必要があります。 これにより、EF Core の将来のバージョンで変更可能なキー値がサポートされるようになっても、正しい行が更新されることが保証されます。

戻り値

EF Core では、ストアド プロシージャから値を返すために 3 つのメカニズムがサポートされています。

  • 出力パラメーター (上で示されているもの)。
  • 結果列。HasResultColumn ビルダー メソッドを使って指定します。
  • 戻り値。影響を受けた行の数を返すことだけに制限され、HasRowsAffectedReturnValue ビルダー メソッドを使って指定します。

ストアド プロシージャから返される値は、多くの場合、生成値、既定値、または計算値に使われます (Identity キーや計算列からのものなど)。 たとえば、次の構成では、4 つの結果列が指定されています。

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

これらは、次の値を返すために使われます。

  • Id プロパティに対して生成されたキー値。
  • FirstRecordedOn プロパティに対してデータベースによって生成された既定値。
  • RetrievedOn プロパティに対してデータベースによって生成された計算値。
  • RowVersion プロパティに対して自動的に生成された rowversion コンカレンシー トークン。

SQL Server を使っていると、この構成は次のストアド プロシージャにマップされます。

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

オプティミスティック コンカレンシー

オプティミスティック同時実行制御は、ストアド プロシージャなしで、それと同じように動作します。 ストアド プロシージャでは次のことが必要です。

  • WHERE 句でコンカレンシー トークンを使って、有効なトークンがある場合にのみ行が更新されるようにします。 コンカレンシー トークンに使われる値は、通常はコンカレンシー トークン プロパティの元の値ですが、そうである必要はありません。
  • 影響を受けた行数を返し、EF Core が予想される影響を受けた行数とこれを比較し、値が一致しない場合に DbUpdateConcurrencyException をスローできるようにします。

たとえば、次の SQL Server のストアド プロシージャでは、rowversion 自動コンカレンシー トークンが使われています。

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

これは、EF Core で次を使って構成されます。

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

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

  • RowVersion コンカレンシー トークンの元の値が使われています。
  • ストアド プロシージャでは、WHERE 句を使って、RowVersion の元の値が一致した場合にのみ行が更新されるようにします。
  • RowVersion に対して生成された新しい値が、一時テーブルに挿入されます。
  • 影響を受けた行の数 (@@ROWCOUNT) と生成された RowVersion 値が返されます。

ストアド プロシージャへの継承階層のマッピング

EF Core では、ストアド プロシージャは階層内の型のテーブル レイアウトに従う必要があります。 これは、次のことを意味します。

  • TPH を使ってマップされた階層には、1 つのマップされたテーブルを対象とする、1 つの挿入、更新、削除ストアド プロシージャが必要です。 挿入と更新のストアド プロシージャには、識別子の値のパラメーターが必要です。
  • TPT を使ってマップされた階層には、抽象型を含むすべての型に対する挿入、更新、削除ストアド プロシージャが必要です。 EF Core は、必要に応じて複数の呼び出しを行って、すべてのテーブルの行を更新、挿入、削除します。
  • TPC を使ってマップされた階層には、すべての具象型に対する挿入、更新、削除ストアド プロシージャが必要ですが、抽象型に対しては必要ありません。

Note

マッピング戦略に関係なく、具象型ごとに 1 つのストアド プロシージャを使うことに関心がある場合は、「継承マッピング戦略に関係なく、具象型ごとに 1 つのストアド プロシージャを使用することのサポート」に投票してください。

ストアド プロシージャへの所有型のマッピング

所有型のストアド プロシージャの構成は、入れ子になった所有型ビルダーで行われます。 次に例を示します。

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Note

現在、挿入、更新、削除用のストアド プロシージャでは、個別のテーブルにマップされる所有型のみがサポートされます。 つまり、所有者テーブルの列で所有型を表すことはできません。 この制限を削除してほしい場合は、「CUD のストアド プロシージャ マッピングに "テーブル" 分割のサポートを追加する」に投票してください。

ストアド プロシージャへの多対多結合エンティティのマッピング

ストアド プロシージャの多対多結合エンティティの構成は、多対多の構成の一部として実行できます。 次に例を示します。

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

新しい、および改良されたインターセプターとイベント

EF Core のインターセプターを使うと、EF Core の操作をインターセプト、変更、抑制できます。 EF Core には、従来の .NET イベントログも含まれています。

EF7 には、インターセプターに対する次の機能強化が含まれています。

さらに、EF7 には、次の場合に対する新しい従来の .NET イベントが含まれます。

以下のセクションでは、これらの新しいインターセプト機能の使用例をいくつか示します。

エンティティの作成に関する簡単なアクション

ヒント

ここで示すコードは SimpleMaterializationSample.cs のものです。

新しい IMaterializationInterceptor では、エンティティ インスタンスが作成される前と後、およびそのインスタンスのプロパティが初期化される前と後のインターセプトがサポートされています。 インターセプターでは、各ポイントでエンティティ インスタンスを変更するか置き換えることができます。 これにより、次のことができます。

  • マップされていないプロパティの設定、または検証、計算値、フラグに必要なメソッドの呼び出し。
  • ファクトリを使用したインスタンスの作成。
  • EF で通常作成されるものとは異なるエンティティ インスタンスの作成 (キャッシュからのインスタンスや、プロキシ型のインスタンスなど)。
  • エンティティ インスタンスへのサービスの挿入。

たとえば、データを編集しているユーザーに表示できるように、エンティティがデータベースから取得されたときを追跡するとします。 これを実現するには、最初にインターフェイスを定義します。

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

インターフェイスを使うことは、多くの異なるエンティティ型で同じインターセプターを使用できるため、インターセプターでは一般的です。 次に例を示します。

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

このプロパティはエンティティの使用中にのみ使われ、データベースに永続化してはならないことを示すため、[NotMapped] 属性が使われていることに注意してください。

その後、インターセプターは、IMaterializationInterceptor から適切なメソッドを実装し、取得した時刻を設定する必要があります。

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

このインターセプターのインスタンスは、DbContext を構成するときに登録されます。

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

ヒント

このインターセプターはステートレスなので (一般的です)、1 つのインスタンスが作成されて、すべての DbContext インスタンス間で共有されます。

これで、データベースで Customer のクエリが実行されるたびに、Retrieved プロパティが自動的に設定されます。 次に例を示します。

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

出力を生成します。

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

エンティティへのサービスの挿入

ヒント

ここで示すコードは InjectLoggerSample.cs のものです。

EF Core には、コンテキスト インスタンスに特別なサービスを挿入するためのサポートが既に組み込まれています。たとえば、ILazyLoader サービスを挿入することで機能する、「プロキシを使用しない遅延読み込み」をご覧ください。

IMaterializationInterceptor を使って、これを任意のサービスに一般化できます。 次の例では、独自のログを実行できるようにエンティティに ILogger を挿入する方法を示します。

Note

エンティティにサービスを挿入すると、それらのエンティティ型が挿入されたサービスに結合されるため、一部のユーザーはそれをアンチパターンと見なします。

前と同様に、インターフェイスを使って、何ができるかを定義します。

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

また、ログするエンティティ型で、このインターフェイスを実装する必要があります。 次に例を示します。

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

ここでは、インターセプターで IMaterializationInterceptor.InitializedInstance を実装する必要があります。これは、すべてのエンティティ インスタンスが作成され、そのプロパティ値が初期化された後に呼び出されます。 インターセプターはコンテキストから ILogger を取得し、それを使って IHasLogger.Logger を初期化します。

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

今度は、取得される ILoggerDbContext インスタンスごとに変わる可能性があり、ILogger がインターセプターにキャッシュされているため、DbContext インスタンスごとにインターセプターの新しいインスタンスが使われます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

これで、Customer.PhoneNumber が変更されるたびに、この変更がアプリケーションのログに記録されます。 次に例を示します。

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

LINQ 式ツリーのインターセプト

ヒント

ここで示すコードは QueryInterceptionSample.cs のものです。

EF Core では、.NET LINQ クエリを使います。 通常は、このとき、C#、VB、または F# コンパイラを使って式ツリーが構築され、EF Core によって適切な SQL に変換されます。 たとえば、顧客のページを返すメソッドを考えてみます。

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

ヒント

このクエリでは、EF.Property メソッドを使って、並べ替えの基準となるプロパティを指定します。 これにより、アプリケーションはプロパティ名を動的に渡すことができ、エンティティ型の任意のプロパティによる並べ替えが可能になります。 インデックスのない列で並べ替えを行うと、遅くなる場合があることに注意してください。

並べ替えに使われるプロパティが常に安定した順序を返す限り、これは問題なく動作します。 ただし、必ずそうであるとは限りません。 たとえば、上記の LINQ クエリでは、Customer.City で並べ替えると、SQLite で次のものが生成されます。

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

複数の顧客が同じ City を持っている場合、このクエリの順序は安定しません。 これにより、複数のページにまたがるデータをユーザーが見たとき、結果の欠落や重複が発生する可能性があります。

この問題を解決する一般的な方法は、主キーで第二の並べ替えを実行することです。 ただし、EF7 では、これをすべてのクエリに手動で追加するのではなく、クエリ式ツリーのインターセプトを使って、第二の順序を動的に追加できます。 これを容易にするため、再びインターフェイスを使いますが、今度は整数の主キーを持つエンティティに対してです。

public interface IHasIntKey
{
    int Id { get; }
}

このインターフェイスは、対象のエンティティ型で実装されます。

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

次に、IQueryExpressionInterceptor を実装するインターセプターが必要です

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

これはおそらくかなり複雑に見えます。そのとおりです。 通常、式ツリーの操作は簡単ではありません。 何が行われているのかを見てみましょう。

  • 基本的に、インターセプターは ExpressionVisitor をカプセル化します。 ビジターは VisitMethodCall をオーバーライドし、クエリ式ツリー内のメソッドが呼び出されるたびに、これが呼び出されます。

  • ビジターは、これが目的の OrderBy メソッドの呼び出しであるかどうかをチェックします。

  • そうである場合、ビジターは、ジェネリック メソッドの呼び出しが、IHasIntKey インターフェイスを実装する型に対するものかどうかをさらにチェックします。

  • この時点で、メソッドの呼び出しが OrderBy(e => ...) という形式であることがわかります。 この呼び出しからラムダ式を抽出し、その式で使われているパラメーター (つまり、e) を取得します。

  • 次に、Expression.Call ビルダー メソッドを使って、新しい MethodCallExpression を構築します。 この場合、呼び出されるメソッドは ThenBy(e => e.Id) です。 これを構築するには、上で抽出したパラメーターと、IHasIntKey インターフェイスの Id プロパティへのプロパティ アクセスを使います。

  • この呼び出しへの入力は元の OrderBy(e => ...) であるため、最終的な結果は OrderBy(e => ...).ThenBy(e => e.Id) の式になります。

  • この変更された式がビジターから返されます。つまり、LINQ クエリが適切に変更され、ThenBy の呼び出しが含まれるようになっています。

  • 引き続き EF Core は、このクエリ式を、使われているデータベースに適した SQL にコンパイルします。

このインターセプターは、最初の例と同じ方法で登録されます。 ここで GetPageOfCustomers を実行すると、次の SQL が生成されます。

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

これにより、同じ City を持つ複数の顧客が存在する場合でも、常に安定した順序が生成されます。

お疲れさまでした。 クエリの簡単な変更なのに、多くのコードが必要になります。 さらに悪いことに、すべてのクエリで機能しない可能性さえあります。 必要なすべてのクエリ形状を認識する式ビジターを記述することは難しいことで知られており、誰も避けて通れません。 たとえば、並べ替えがサブクエリで行われた場合、これは機能しない可能性があります。

これは、インターセプターに関する重要なポイントです。必要な操作をもっと簡単に行う方法があるかどうかを常に自問してください。 インターセプターは強力ですが、簡単に問題が発生します。 格言にもあるように、それは墓穴を掘る簡単な方法です。

たとえば、代わりに GetPageOfCustomers メソッドを次のように変更したとします。

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

この場合は、ThenBy が単にクエリに追加されます。 そうです。すべてのクエリに個別に行う必要があるかもしれませんが、単純でわかりやすいため、常に機能します。

オプティミスティック同時実行制御インターセプト

ヒント

ここで示すコードは OptimisticConcurrencyInterceptionSample.cs のものです。

EF Core では、更新または削除によって実際に影響を受けた行数が、影響を受けると予想された行数と同じであることを調べることにより、オプティミスティック同時実行制御パターンがサポートされています。 多くの場合、これはコンカレンシー トークンと組み合わせて使われます。つまり、予想された値が読み取られた後で行が更新されていない場合にのみ、予想された値と一致する列の値です。

EF は、DbUpdateConcurrencyException をスローすることで、オプティミスティック同時実行制御の違反を通知します。 EF7 の ISaveChangesInterceptor には、DbUpdateConcurrencyException がスローされる前に呼び出される新しいメソッド ThrowingConcurrencyExceptionThrowingConcurrencyExceptionAsync があります。 これらのインターセプト ポイントを使うと、例外を抑制でき、場合によってはデータベースの非同期変更と組み合わせて違反を解決できます。

たとえば、2 つの要求が同じエンティティをほぼ同時に削除しようとした場合、データベースに行が存在しなくなるため、2 番目の削除は失敗する可能性があります。 これは問題ないかもしれません。いずれにしても最終的には、エンティティが削除されています。 次のインターセプターは、これを行う方法を示しています。

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

このインターセプターについて注目すべき点がいくつかあります。

  • 同期と非同期両方のインターセプト メソッドが実装されています。 これは、アプリケーションが SaveChanges または SaveChangesAsync を呼び出す可能性がある場合に重要です。 ただし、すべてのアプリケーション コードが非同期の場合は、ThrowingConcurrencyExceptionAsync のみを実装する必要があります。 同様に、アプリケーションが非同期データベース メソッドを使わない場合は、ThrowingConcurrencyException のみを実装する必要があります。 これは、一般に、同期と非同期のメソッドがあるすべてのインターセプターに当てはまります。 (同期または非同期コードが紛れ込んだときのためだけに、アプリケーションがスローに使わないメソッドを実装することは価値があるかもしれません)。
  • インターセプターは、保存されるエンティティの EntityEntry オブジェクトにアクセスできます。 この場合、これは、削除操作でコンカレンシー違反が発生しているかどうかを調べるために使われます。
  • アプリケーションでリレーショナル データベース プロバイダーを使っている場合は、ConcurrencyExceptionEventData オブジェクトを RelationalConcurrencyExceptionEventData オブジェクトにキャストできます。 これにより、実行されているデータベース操作に関するリレーショナル固有の追加情報が提供されます。 この場合は、リレーショナル コマンドのテキストがコンソールに出力されます。
  • InterceptionResult.Suppress() が返された場合は、実行しようとしていたアクション (この場合は DbUpdateConcurrencyException のスロー) を抑制することが EF Core に指示されます。 EF Core の動作を単に観察するのではなく、"EF Core の動作を変更する" この機能は、インターセプターの最も強力な機能の 1 つです。

接続文字列の遅延初期化

ヒント

ここで示すコードは LazyConnectionStringSample.cs のものです。

接続文字列は、多くの場合、構成ファイルから読み取られる静的資産です。 これらは、DbContext を構成するときに、UseSqlServer などに簡単に渡すことができます。 ただし、コンテキスト インスタンスごとに接続文字列が変わる場合があります。 たとえば、マルチテナント システムでは、テナントごとに接続文字列が異なることがあります。

EF7 では、IDbConnectionInterceptor の機能強化により、動的な接続と接続文字列の処理がいっそう簡単になります。 その最初のものは、接続文字列なしで DbContext を構成する機能です。 次に例を示します。

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

次に、IDbConnectionInterceptor メソッドの 1 つを実装して、使う前に接続を構成できます。 接続文字列の取得やアクセス トークンの検索などの非同期操作を実行できるため、ConnectionOpeningAsync が適切な選択肢です。 たとえば、現在のテナントを理解する現在の要求をスコープとするサービスを想像してください。

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

警告

接続文字列やアクセス トークンなどの非同期検索を必要なたびに実行すると、非常に時間がかかる場合があります。 これらの項目をキャッシュし、キャッシュされた文字列またはトークンのみを定期的に更新することを検討してください。 たとえば、多くの場合、アクセス トークンは、更新が必要になる前に、かなりの期間使用できます。

これは、コンストラクターの挿入を使って、各 DbContext インスタンスに挿入できます。

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

このサービスは、コンテキスト用にインターセプター実装を構築するときに使われます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

最後に、インターセプターは、このサービスを使って接続文字列を非同期的に取得し、接続が初めて使われるときに設定します。

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Note

接続文字列は、接続が初めて使われるときにのみ取得されます。 その後は、DbConnection に格納されている接続文字列が使われ、新しい接続文字列の検索は行われません。

ヒント

接続文字列を取得するサービスは非同期コード パスから呼び出す必要があるため、このインターセプターは非同期ではない ConnectionOpening メソッドをオーバーライドしてスローします。

SQL Server のクエリ統計のログ

ヒント

ここで示すコードは QueryStatisticsLoggerSample.cs のものです。

最後に、連係して SQL Server のクエリ統計情報をアプリケーション ログに送信する 2 つのインターセプターを作成しましょう。 統計情報を生成するには、IDbCommandInterceptor で 2 つのことを行う必要があります。

1 つ目として、インターセプターはコマンドに SET STATISTICS IO ON というプレフィックスを付けます。これは、結果セットが使用された後でクライアントに統計を送信するよう SQL Server に指示します。

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

2 つ目として、インターセプターは新しい DataReaderClosingAsync メソッドを実装します。これは、DbDataReader が結果の使用を終了した後、それが閉じられる "前" に呼び出されます。 SQL Server は、統計を送信するとき、リーダーの 2 番目の結果にそれを配置するため、この時点でインターセプターは、接続に統計を設定する NextResultAsync を呼び出して、その結果を読み取ります。

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

2 番目のインターセプターは、接続から統計を取得し、アプリケーションのロガーに書き込むために必要です。 このためには、新しい ConnectionCreatedメソッドを実装している IDbConnectionInterceptor を使います。 ConnectionCreated は、EF Core が接続を作成した直後に呼び出されるため、その接続の追加構成を行うために使用できます。 この場合、インターセプターは、ILogger を取得してから、SqlConnection.InfoMessage イベントにフックしてメッセージをログします。

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

重要

ConnectionCreatingConnectionCreated メソッドは、EF Core が DbConnection を作成するときにのみ呼び出されます。 アプリケーションで DbConnection を作成して EF Core に渡す場合、これらは呼び出されません。

これらのインターセプターを使用するコードを実行すると、ログで SQL Server クエリの統計情報が示されます。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

クエリの機能強化

EF7 では、LINQ クエリの変換に多くの機能強化が含まれています。

最後の演算子としての GroupBy

ヒント

ここで示すコードは GroupByFinalOperatorSample.cs のものです。

EF7 では、クエリの最後の演算子として GroupBy を使用できます。 たとえば、次の LINQ クエリ:

var query = context.Books.GroupBy(s => s.Price);

SQL Server を使っている場合は、次の SQL に変換されます。

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

Note

この種類の GroupBy は SQL に直接変換されないため、EF Core が返された結果に対するグループ化を行います。 ただし、これによりサーバーから追加のデータが転送されることはありません。

最後の演算子としての GroupJoin

ヒント

ここで示すコードは GroupJoinFinalOperatorSample.cs のものです。

EF7 では、クエリの最後の演算子として GroupJoin を使用できます。 たとえば、次の LINQ クエリ:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

SQL Server を使っている場合は、次の SQL に変換されます。

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

GroupBy エンティティ型

ヒント

ここで示すコードは GroupByEntityTypeSample.cs のものです。

EF7 では、エンティティ型でグループ化できます。 たとえば、次の LINQ クエリ:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

SQLite を使っている場合は、次の SQL に変換されます。

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

エンティティ型によるグループ化より、主キーなどの一意のプロパティによるグループ化の方が常に効率的であることに注意してください。 ただし、エンティティ型によるグループ化は、キー付きとキーレスの両方のエンティティ型で使用できます。

また、主キーでエンティティ型によるグループ化を行うと、すべてのエンティティが一意のキー値を持つ必要があるため、常にエンティティ インスタンスごとに 1 つのグループが生成されます。 グループ化が必要ないように、クエリのソースを切り替えると役に立つことがあります。 たとえば、次のクエリは、前のクエリと同じ結果を返します。

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

SQLite を使っている場合、このクエリは次の SQL に変換されます。

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

サブクエリが外部クエリからグループ化されていない列を参照しない

ヒント

ここで示すコードは UngroupedColumnsQuerySample.cs のものです。

EF Core 6.0 では、GROUP BY 句は外部クエリの列を参照しており、一部のデータベースでは失敗し、他のデータベースでは非効率的です。 たとえば、次のクエリを考えてみましょう。

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

SQL Server 上の EF Core 6.0 では、これは次のように変換されました。

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

EF7 では、次のように変換されます。

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

読み取り専用コレクションは Contains に使用できる

ヒント

ここで示すコードは ReadOnlySetQuerySample.cs のものです。

EF7 では、検索対象項目が IReadOnlySetIReadOnlyCollection、または IReadOnlyList に含まれている場合、Contains の使用がサポートされます。 たとえば、次の LINQ クエリ:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

SQL Server を使っている場合は、次の SQL に変換されます。

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

集計関数の変換

EF7 では、集計関数の変換に関するプロバイダーの拡張性が向上します。 この領域でのこれや他の作業により、プロバイダー全体で次のようないくつかの新しい変換が行われます。

Note

IEnumerable 引数を操作する集計関数は、通常、GroupBy クエリでのみ変換されます。 この制限を取り除いてほしい場合は、「JSON 列の空間型をサポートする」に投票してください。

文字列集計関数

ヒント

ここで示すコードは StringAggregateFunctionsSample.cs のものです。

JoinConcat を使うクエリは、必要に応じて変換されるようになりました。 次に例を示します。

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

SQL Server を使っている場合、このクエリは次のように変換されます。

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

これらの変換を他の文字列関数と組み合わせると、サーバーでいくつかの複雑な文字列操作が可能になります。 次に例を示します。

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

SQL Server を使っている場合、このクエリは次のように変換されます。

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

空間集計関数

ヒント

ここで示すコードは SpatialAggregateFunctionsSample.cs のものです。

NetTopologySuite をサポートするデータベース プロバイダーが、次の空間集計関数を変換できるようになりました。

ヒント

これらの変換は、SQL Server と SQLite のチームによって実装されています。 他のプロバイダーで、そのプロバイダーに対して実装されている場合は、プロバイダーの保守担当者に問い合わせてサポートを追加してください。

次に例を示します。

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

SQL Server を使っている場合、このクエリは次の SQL に変換されます。

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

統計集計関数

ヒント

ここで示すコードは StatisticalAggregateFunctionsSample.cs のものです。

SQL Server の変換は、次の統計関数について実装されています。

ヒント

これらの変換は、SQL Server のチームによって実装されています。 他のプロバイダーで、そのプロバイダーに対して実装されている場合は、プロバイダーの保守担当者に問い合わせてサポートを追加してください。

次に例を示します。

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

SQL Server を使っている場合、このクエリは次の SQL に変換されます。

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

string.IndexOf の変換

ヒント

ここで示すコードは MiscellaneousTranslationsSample.cs のものです。

EF7 は LINQ クエリの String.IndexOf を変換するようになりました。 次に例を示します。

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

SQL Server を使っている場合、このクエリは次の SQL に変換されます。

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

エンティティ型の GetType の変換

ヒント

ここで示すコードは MiscellaneousTranslationsSample.cs のものです。

EF7 は LINQ クエリの Object.GetType() を変換するようになりました。 次に例を示します。

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

TPH 継承で SQL Server を使っている場合、このクエリは次の SQL に変換されます。

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

このクエリは、実際に Post 型である Post インスタンスのみを返し、派生型のインスタンスは返さないことに注意してください。 これは、派生型のインスタンスも返す is または OfType を使うクエリとは異なります。 たとえば、次のクエリを考えてみます。

var query = context.Posts.OfType<Post>();

これは異なる SQL に変換されます。

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

そして、PostFeaturedPost の両方のエンティティを返します。

AT TIME ZONE のサポート

ヒント

ここで示すコードは MiscellaneousTranslationsSample.cs のものです。

EF7 では、DateTimeDateTimeOffset 用に新しい AtTimeZone 関数が導入されています。 これらの関数は、生成される SQL では AT TIME ZONE 句に変換されます。 次に例を示します。

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

SQL Server を使っている場合、このクエリは次の SQL に変換されます。

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

ヒント

これらの変換は、SQL Server のチームによって実装されています。 他のプロバイダーで、そのプロバイダーに対して実装されている場合は、プロバイダーの保守担当者に問い合わせてサポートを追加してください。

非表示のナビゲーションでフィルター処理される Include

ヒント

ここで示すコードは MiscellaneousTranslationsSample.cs のものです。

Include メソッドを、EF.Property で使用できるようになりました。 これにより、プライベート ナビゲーション プロパティやフィールドで表されるプライベート ナビゲーションの場合でも、フィルター処理と並べ替えを行うことができます。 次に例を示します。

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

このようにすると、次の記述と同じ結果が得られます。

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

ただし、Blog.Posts にパブリックにアクセスできる必要はありません。

SQL Server を使っている場合、上のクエリはどちらも次のように変換されます。

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Regex.IsMatch の Cosmos 変換

ヒント

ここで示すコードは CosmosQueriesSample.cs のものです。

EF7 では、Azure Cosmos DB に対する LINQ クエリでの Regex.IsMatch の使用がサポートされています。 次に例を示します。

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

次の SQL に変換されます。

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

DbContext API と動作の機能強化

EF7 には、DbContext および関連するクラスに対するさまざまな小さい機能強化が含まれています。

ヒント

このセクションのサンプルのコードは、DbContextApiSample.cs のものです。

初期化前の状態に戻された DbSet プロパティのサプレッサー

DbContext のパブリックで設定可能な DbSet プロパティは、DbContext が構築されるときに、EF Core によって自動的に初期化されます。 たとえば、次のような DbContext 定義について考えます。

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

Blogs プロパティは、DbContext インスタンスの構築の一部として、DbSet<Blog> インスタンスに設定されます。 これにより、追加手順なしでコンテキストをクエリに使用できます。

ただし、C# の null 許容参照型が導入された後、コンパイラは null 非許容プロパティ Blogs が初期化されていないことを警告するようになりました。

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

これは誤った警告です。このプロパティは、EF Core によって null 以外の値に設定されます。 また、プロパティを null 許容として宣言すると警告は消えますが、概念的にはプロパティは null 許容ではなく、null になることはないため、これはよい方法ではありません。

EF7 には、DbContext 上の DbSet プロパティに対して、この警告がコンパイラで生成されないようにする DiagnosticSuppressor が含まれます。

ヒント

このパターンは、C# の自動プロパティが非常に制限されていたときに始まりました。 最新の C# では、自動プロパティを読み取り専用にしてから、DbContext コンストラクターで明示的に初期化するか、必要に応じてコンテキストからキャッシュされた DbSet インスタンスを取得することを検討してください。 たとえば、「 public DbSet<Blog> Blogs => Set<Blog>() 」のように入力します。

ログで取り消しとエラーを区別する

場合によっては、アプリケーションでクエリまたは他のデータベース操作を明示的に取り消すことがあります。 通常、これは、操作を実行するメソッドに渡される CancellationToken を使って行われます。

EF Core 6 では、操作が取り消されたときにログに記録されるイベントが、何らかの理由で操作が失敗したときに記録されるイベントと同じです。 EF7 では、取り消されたデータベース操作専用の新しいログ イベントが導入されます。 これらの新しいイベントは、既定では Debug レベルでログされます。 次の表では、関連するイベントとその既定のログ レベルを示します。

Event 説明 既定のログ レベル
CoreEventId.QueryIterationFailed クエリの結果の処理中にエラーが発生しました。 LogLevel.Error
CoreEventId.SaveChangesFailed データベースに変更を保存しようとしてエラーが発生しました。 LogLevel.Error
RelationalEventId.CommandError データベース コマンドの実行中にエラーが発生しました。 LogLevel.Error
CoreEventId.QueryCanceled クエリが取り消されました。 LogLevel.Debug
CoreEventId.SaveChangesCanceled 変更を保存しようとしている間に、データベース コマンドが取り消されました。 LogLevel.Debug
RelationalEventId.CommandCanceled DbCommand の実行が取り消されました。 LogLevel.Debug

Note

取り消しは、キャンセル トークンをチェックするのではなく、例外を調べることで検出されます。 つまり、キャンセル トークンによってトリガーされない取り消しは引き続き検出され、この方法でログされます。

EntityEntry メソッドに対する新しい IProperty および INavigation オーバーロード

EF モデルを使用しているコードには、プロパティまたはナビゲーション メタデータを表す IProperty または INavigation が含まれることがよくあります。 その場合、プロパティやナビゲーションの値を取得したり、その状態のクエリを実行したりするには、EntityEntry が使われます。 ただし、EF7 より前では、プロパティまたはナビゲーションの "名前" を EntityEntry のメソッドに渡す必要があり、それによって IProperty または INavigation が再検索されました。 EF7 では、代わりに IProperty または INavigation を直接渡すことができるので、追加の参照を回避できます。

たとえば、特定のエンティティのすべての兄弟を検索するメソッドについて考えます。

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

このメソッドは、特定のエンティティの親を検索してから、親エントリの Collection メソッドに逆 INavigation を渡します。 その後、このメタデータを使って、指定された親のすべての兄弟が返されます。 その使用例を次に示します。


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

出力は次のとおりです。

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

共有型のエンティティ型の EntityEntry

EF Core では、複数の異なるエンティティ型に同じ CLR 型を使用できます。 これらは "共有型のエンティティ型" と呼ばれ、多くの場合、エンティティ型のプロパティに使われるキーと値のペアを含むディクショナリ型をマップするために使われます。 たとえば、BuildMetadata エンティティ型は、専用の CLR 型を定義せずに定義できます。

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

共有型のエンティティ型には名前を付ける必要があることに注意してください。この場合の名前は BuildMetadata です。 これらのエンティティ型にアクセスするには、名前を使って取得したエンティティ型の DbSet を使います。 次に例を示します。

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

この DbSet を使って、エンティティのインスタンスを追跡できます。

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

そして、クエリを実行します。

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

現在、EF7 には、DbSetEntry メソッドもあり、これを使うと、"まだ追跡されていない場合でも"、インスタンスの状態を取得できます。 次に例を示します。

var state = context.BuildMetadata.Entry(build).State;

ContextInitializedDebug としてログされるようになった

EF7 では、ContextInitialized イベントは Debug レベルでログされます。 次に例を示します。

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

以前のリリースでは、Information レベルでログされていました。 次に例を示します。

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

必要に応じて、ログ レベルを Information に戻すことができます。

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator をパブリックに使用できる

EF7 では、IEntityEntryGraphIterator サービスをアプリケーションで使用できます。 これは、追跡するエンティティのグラフを検出するときに内部的に使われるサービスであり、TrackGraph によっても使われます。 いくつかの開始エンティティから到達可能なすべてのエンティティを反復処理する例を次に示します。

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

注意:

  • コールバック デリゲートが false を返すと、反復子は特定のノードからの走査を停止します。 この例では、アクセスされたエンティティを追跡し、エンティティが既にアクセスされているときは false を返します。 これにより、グラフ内のサイクルに起因する無限ループが回避されます。
  • EntityEntryGraphNode<TState> オブジェクトを使うと、状態をデリゲートにキャプチャすることなく渡すことができます。
  • 最初以外のすべてのアクセスされたノードについて、検出元のノードとナビゲーションがコールバックに渡されます。

モデルの構築の機能強化

EF7 には、モデルの構築に関するさまざまな小さい機能強化が含まれます。

ヒント

このセクションのサンプルのコードは ModelBuildingSample.cs のものです。

インデックスは昇順でも降順でもかまわない

既定では、EF Core は昇順のインデックスを作成します。 EF7 では、降順インデックスの作成もサポートされています。 次に例を示します。

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

または、Index マッピング属性を使います。

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

データベースは両方向の順序付けに同じインデックスを使用できるため、これは 1 つの列に対するインデックスにはほとんど役立ちません。 ただし、各列の順序が重要になる可能性がある複数の列に対する複合インデックスの場合には当てはまりません。 EF Core は、複数の列で列ごとに異なる順序を定義できるようにすることで、これをサポートします。 次に例を示します。

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

または、マッピング属性を使います。

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

SQL Server を使っている場合、これは次の SQL になります。

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

最後に、インデックス名を指定することで、同じ順序付けされた列のセットに対して複数のインデックスを作成できます。 次に例を示します。

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

または、マッピング属性を使います。

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

これにより、SQL Server では次の SQL が生成されます。

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

複合キーのマッピング属性

EF7 では、エンティティ型の主キー プロパティまたはプロパティを指定するための新しいマッピング属性 ("データ注釈" とも呼ばれます) が導入されます。 System.ComponentModel.DataAnnotations.KeyAttribute とは異なり、PrimaryKeyAttribute はキー プロパティではなくエンティティ型クラスに配置されます。 次に例を示します。

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

これにより、複合キーの定義に自然に適合します。

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

クラスでインデックスを定義すると、EF モデルを構築するときに通常は無視されますが、プライベート プロパティまたはフィールドをキーとして指定するために使うこともできます。 次に例を示します。

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior マッピング属性

EF7 では、リレーションシップの DeleteBehavior を指定するためのマッピング属性 ("データ注釈" とも呼ばれます) が導入されています。 たとえば、必須リレーションシップは、既定では DeleteBehavior.Cascade で作成されます。 これを、DeleteBehaviorAttribute を使って既定で DeleteBehavior.NoAction に変更できます。

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

これにより、Blog-Posts リレーションシップのカスケード削除が無効になります。

異なる列名にマップされたプロパティ

一部のマッピング パターンでは、同じ CLR プロパティが複数の異なるテーブルの列にマップされます。 EF7 では、これらの列に異なる名前を付けることができます。 たとえば、簡単な継承階層を考えてみます。

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

TPT 継承マッピング戦略では、これらの型は 3 つのテーブルにマップされます。 ただし、各テーブルの主キー列の名前は異なっていてもかまいません。 次に例を示します。

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 では、入れ子になったテーブル ビルダーを使って、このマッピングを構成できます。

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

TPC 継承マッピングを使うと、Breed プロパティを異なるテーブルの異なる列名にマップすることもできます。 たとえば、次のような TPC テーブルについて考えます。

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 では、次のテーブル マッピングがサポートされています。

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

一方向の多対多リレーションシップ

EF7 では、一方または他方にナビゲーション プロパティがない多対多リレーションシップがサポートされています。 たとえば、Post 型と Tag 型について考えます。

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Post 型にはタグのリストに対するナビゲーション プロパティがありますが、Tag 型には投稿に対するナビゲーション プロパティがないことに注意してください。 EF7 では、これも多対多リレーションシップとして構成できるため、同じ Tag オブジェクトを多くの異なる投稿に使用できます。 次に例を示します。

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

これにより、適切な結合テーブルにマッピングされます。

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

また、このリレーションシップは通常の方法で多対多として使用できます。 たとえば、共通セットからさまざまなタグを共有するいくつかの投稿を挿入します。

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

エンティティ分割

エンティティ分割は、1 つのエンティティ型を複数のテーブルにマップします。 たとえば、顧客データを保持する 3 つのテーブルを持つデータベースを考えてみます。

  • 顧客情報のための Customers テーブル
  • 顧客の電話番号のための PhoneNumbers テーブル
  • 顧客の住所のための Addresses テーブル

SQL Server でのこれらのテーブルの定義は次のようになります。

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

これらの各テーブルは、通常、型の間のリレーションシップを使って、独自のエンティティ型にマップされます。 ただし、常に 3 つのテーブルすべてが一緒に使われる場合は、それらすべてを 1 つのエンティティ型にマップする方が便利です。 次に例を示します。

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

EF7 では、これはエンティティ型の分割ごとに SplitToTable を呼び出すことによって実現されます。 たとえば、次のコードでは、Customer エンティティ型が、上に示した CustomersPhoneNumbersAddresses の各テーブルに分割されます。

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

また、必要な場合は、各テーブルに対して異なる主キー列名を指定できることにも注意してください。

SQL Server の UTF-8 文字列

nchar および nvarchar データ型によって表される SQL Server の Unicode 文字列は、UTF-16 として格納されます。 さらに、char および varchar データ型は、さまざまな文字セットのサポートを備え、Unicode 以外の文字列を格納するために使われます。

SQL Server 2019 以降では、char および varchar データ型を、UTF-8 エンコードの Unicode 文字列を代わりに格納するために使用できます。 これは、UTF-8 照合順序の 1 つを設定することによって実現されます。 たとえば、次のコードは、CommentText 列用の可変長 SQL Server UTF-8 文字列を構成します。

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

この構成では、次の SQL Server 列定義が生成されます。

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

一時テーブルが所有エンティティをサポートする

EF7 では、EF Core の SQL Server 一時テーブルのマッピングが、テーブル共有をサポートするように強化されています。 特に、単一所有エンティティの既定のマッピングでは、テーブル共有が使われます。

たとえば、所有者エンティティ型 Employee とその所有エンティティ型 EmployeeInfo について考えます。

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

これらの型が同じテーブルにマップされている場合、EF7 では、そのテーブルを一時テーブルにすることができます。

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Note

この構成を簡単にする作業は、Issue #29303 で追跡されています。 この実装が必要な場合は、この issue に投票してください。

値の生成の強化

EF7 には、キー プロパティの値の自動生成に関する 2 つの重要な機能強化が含まれています。

ヒント

このセクションのサンプルのコードは ValueGenerationSample.cs のものです。

DDD で保護された型の値の生成

ドメイン駆動設計 (DDD) では、"保護されたキー" を使って、キー プロパティのタイプ セーフを向上させることができます。 これは、キーの使用に固有の別の型でキーの型をラップすることによって実現されます。 たとえば、次のコードでは、製品キー用の ProductId 型と、カテゴリ キー用の CategoryId 型が定義されています。

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

これらは、Product および Category エンティティ型で使われます。

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

これにより、カテゴリの ID を誤って製品に割り当てることができなくなります。その逆も同様です。

警告

多くの DDD の概念と同様、このタイプ セーフの向上と引き換えに、コードの複雑さが増します。 たとえば、カテゴリへの製品 ID の割り当てが発生する可能性があることかどうかを検討するのは、価値があることです。 ものごとを単純な状態にしておくことの方が、コードベース全体にとってはいっそう有益かもしれません。

ここで示す保護されたキー型は両方とも int キー値をラップします。これは、マップされたデータベース テーブルで整数値が使われることを意味します。 これは、型に値コンバーターを定義することによって実現されます。

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Note

ここでのコードでは、struct 型を使用します。 これは、キーとして使用するための適切な値型セマンティクスがあることを意味します。 代わりに class 型を使う場合は、等値セマンティクスをオーバーライドするか、値比較子も指定する必要があります。

EF7 では、値コンバーターに基づくキー型は、基になる型でサポートされている限り、自動的に生成されるキー値を使用できます。 これは、ValueGeneratedOnAdd を使って通常の方法で構成されます。

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

SQL Server で使用した場合、これは既定では IDENTITY 列になります。

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

これは、エンティティを挿入するときにキー値を生成するための通常の方法で使われます。

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

SQL Server 用のシーケンス ベースのキー生成

EF Core では、SQL Server の IDENTITYを使用する、またはデータベース シーケンスによって生成されたキーのブロックに基づく Hi-Lo パターンを使用する、キー値の生成がサポートされます。 EF7 では、キーの列の既定の制約にアタッチされたデータベース シーケンスのサポートが導入されています。 最も簡単な形式では、キー プロパティに対してシーケンスを使うよう EF Core に指示するだけで済みます。

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

これにより、データベースでシーケンスが定義されるようになります。

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

その後、それはキー列の既定の制約で使われます。

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Note

この形式のキー生成は、TPC マッピング戦略を使用するエンティティ型階層で生成されるキーに既定で使われます。

必要に応じて、シーケンスに別の名前とスキーマを指定できます。 次に例を示します。

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

シーケンスの追加の構成は、モデルでそれを明示的に構成することによって形成されます。 次に例を示します。

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

移行ツールの機能強化

EF7 には、EF Core Migrations コマンド ライン ツールを使用するときの 2 つの重要な機能強化が含まれています。

UseSqlServer などが null を受け入れる

構成ファイルから接続文字列を読み取り、その接続文字列を UseSqlServerUseSqlite、または別のプロバイダーの同等のメソッドに渡すことが非常に一般的です。 次に例を示します。

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

また、移行を適用するときに接続文字列を渡すのも一般的です。 次に例を示します。

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

または、Migrations バンドルを使用する場合。

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

この場合、構成から読み取られた接続文字列は使われませんが、それでもアプリケーションのスタートアップ コードで、構成からそれを読み取り、UseSqlServer に渡そうとします。 構成を使用できない場合は、null を UseSqlServer に渡すことになります。 EF7 では、コマンド ライン ツールに --connection を渡すなどして、後で接続文字列が最終的に設定される限り、これは許可されます。

Note

この変更は、UseSqlServerUseSqlite に対して行われています。 他のプロバイダーの場合は、プロバイダーの保守担当者に連絡し、そのプロバイダーに対してまだ行われていない場合は、同等の変更を行います。

ツールが実行されていることを検出する

EF Core は、dotnet-ef または PowerShell コマンドが使われているときに、アプリケーション コードを実行します。 デザイン時に、不適切なコードが実行されないように、この状況を検出することが必要な場合があります。 たとえば、開始時に自動的に移行を適用するコードでは、おそらく、設計時にはこれを行わないようにする必要があります。 EF7 では、EF.IsDesignTime フラグを使ってこれを検出できます。

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

EF Core は、アプリケーション コードがツールに代わって実行されていると、IsDesignTimetrue に設定します。

プロキシのパフォーマンスの強化

EF Core では、遅延読み込み変更追跡のため、動的に生成されるプロキシがサポートされます。 EF7 には、これらのプロキシの使用時のパフォーマンス向上が 2 つ含まれます。

  • プロキシ型が遅延作成されるようになりました。 つまり、プロキシを使っているときの初期モデルの構築時間が、EF7 では EF Core 6.0 より大幅に短縮されています。
  • コンパイル済みモデルでプロキシを使用できるようになりました。

449 のエンティティ型、6390 のプロパティ、720 のリレーションシップを含むモデルでのパフォーマンス結果を次に示します。

シナリオ Method 平均 エラー StdDev
EF Core 6.0 でプロキシなし TimeToFirstQuery 1.085 秒 0.0083 秒 0.0167 秒
EF Core 6.0 で変更追跡プロキシあり TimeToFirstQuery 13.01 秒 0.2040 秒 0.4110 秒
EF Core 7.0 でプロキシなし TimeToFirstQuery 1.442 秒 0.0134 秒 0.0272 秒
EF Core 7.0 で変更追跡プロキシあり TimeToFirstQuery 1.446 秒 0.0160 秒 0.0323 秒
EF Core 7.0 で変更追跡プロキシとコンパイル済みモデルあり TimeToFirstQuery 0.162 秒 0.0062 秒 0.0125 秒

この場合、変更追跡プロキシを使用するモデルは、EF Core 6.0 より EF7 の方が 80 倍速く、最初のクエリを実行できる状態になります。

ファースト クラスの Windows フォームのデータ バインディング

Windows フォーム チームは、Visual Studio デザイナーのエクスペリエンスに対して大きな改善をいくつか行っています。 これには、EF Core との統合が向上するデータ バインディングの新しいエクスペリエンスが含まれます。

簡単に言うと、新しいエクスペリエンスでは、ObjectDataSource を作成するための Visual Studio UI が提供されます。

Choose Category data source type

その後、簡単なコードを使って EF Core の DbSet にこれをバインドできます。

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Windows フォームについて」の完全なチュートリアルと、ダウンロード可能な WinForms サンプル アプリケーションをご覧ください。