コンテンツにスキップ

付録 EF Core 1:モデル定義(エンティティとリレーションシップ)

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。エンティティの構成方法、リレーションシップの定義、値の変換と継承のマッピングを扱います。

本編を先に読んでから、必要な項目をここで参照してください。

第8章のほかの付録


  1. エンティティの構成
  2. リレーションシップ
  3. 値の変換と型のマッピング
  4. 参考ドキュメント

パラメーター付きコンストラクターへのバインド

Section titled “パラメーター付きコンストラクターへのバインド”

EF Core は、エンティティを作るときに パラメーター付きコンストラクターを呼ぶことができます。公式のコンストラクターの説明によると、マップされたプロパティと名前・型が一致するパラメーターを持つコンストラクターが見つかれば、既定の引数なしコンストラクターの代わりにそちらが呼ばれます。

public class Book
{
public Book(int id, string title)
{
Id = id;
Title = title;
}
public int Id { get; private set; }
public string Title { get; private set; }
public string Note { get; set; } = "";
public string Computed => Title + "!"; // セッターがないのでマップされない
}

上のモデルの確認例では、読込時に (int id, string title) が呼ばれ、コンストラクターで受け取らない Note は後から設定されることを確認できています。private set も EF Core による設定の対象です。

公式ドキュメントが挙げている注意点のうち、実務で効いてくるのは次の点です。

  • すべてのプロパティにコンストラクターパラメーターが必要なわけではない。受け取らなかったプロパティは通常どおり後から設定される
  • パラメーターの型と名前はプロパティと一致する必要がある。ただしプロパティがパスカルケース、パラメーターがキャメルケースという違いは許される
  • EF Core はナビゲーションプロパティをコンストラクター引数へバインドできない(自分のコンストラクターのコードでコレクションを初期化することを禁止しているわけではない)
  • 通常の実体化では private や internal のコンストラクターも使用できます。遅延読み込みプロキシでは、派生プロキシから呼び出せる public / protected などのコンストラクターが必要です。
  • セッターを持たないプロパティは規約でマップされない。 上のモデルの確認例でも Computed の列は生成されていません。読み取り専用にしたい場合は private set を使ってください
  • 自動生成のキー値を使う場合、キープロパティは読み書き可能である必要がある

エンティティのコンストラクターにサービスを注入できる(が、勧められない)

Section titled “エンティティのコンストラクターにサービスを注入できる(が、勧められない)”

EF Core はエンティティのコンストラクターに、EF Core が知っているサービスを注入できます。公式ドキュメントが挙げているのは DbContext、ILazyLoader(遅延読み込みサービス)、Action<object, string>(遅延読み込みデリゲート)、IEntityType(そのエンティティ型のメタデータ)の 4 つです。アプリケーション側のサービスは注入できません。

public class Blog
{
public Blog() { }
private Blog(ShopDbContext context) => Context = context; // EF Core によるマテリアライズ用
private ShopDbContext? Context { get; set; }
public int Id { get; set; }
public ICollection<Post>? Posts { get; set; }
// 記事を全件読み込まずに件数だけを取る
public int PostsCount
=> Posts?.Count
?? Context?.Set<Post>().Count(p => Id == EF.Property<int?>(p, "BlogId"))
?? 0;
}

投稿 3 件を持つブログの確認例では、EF Core がクエリ結果として生成したインスタンスの PostsCount は 3、new Blog() したインスタンスでは 0 です。上のコードが Context?. と null 条件演算子を使うのは、自分で new Blog() する経路では Context が注入されないためです。

Null 許容参照型がスキーマを決める

Section titled “Null 許容参照型がスキーマを決める”

C# の Null 許容参照型 (nullable reference types: NRT) は、新規プロジェクトのテンプレートでは既定で有効です。EF Core はこの注釈を読んで、列を NOT NULL にするか NULL にするかを決めます。同じプロパティ宣言でも、NRT の有効・無効で生成されるスキーマが変わります。

public class Customer
{
public int Id { get; set; }
public required string Name { get; set; } // 非 null
public string? Nickname { get; set; } // null 許容
}
-- SQLite。NRT が有効な場合(実測)
"Name" TEXT NOT NULL,
"Nickname" TEXT NULL
-- NRT が無効な場合、同じ宣言でも
"Name" TEXT NULL,
"Nickname" TEXT NULL

NRT を有効にすると、初期化されていない非 null プロパティに対してコンパイラーが CS8618 を出します。C# 11 以降なら required 修飾子が最も素直な解決策です。コンストラクターで初期化する方法もありますが、EF Core によるナビゲーションのコンストラクターバインドには対応していません。

ナビゲーションプロパティを null 許容にするかどうかは、公式が次の指針を示しています。

状況推奨
読み込まずにナビゲーションへアクセスするのはプログラマーの誤りだと考える非 null にする(= null!; で初期化)
null かどうかで「読み込み済みか」を判定したいnull 許容にする
コレクションナビゲーション常に非 null。 関連が無いことは空のコレクションで表す

EF Core はモデルを 3 段階で構成します。優先順位は下にあるものほど強くなります。

flowchart TB
    accTitle: モデル構成の方法と優先順位
    accDescr: 規約による推論、データ注釈、Fluent API の順に構成方法を示す。同じ設定が競合する場合は後の方法が優先される。
    A["1. 規約 (Conventions)<br>命名規則から自動的に推論"] --> B["2. データ注釈 (Data Annotations)<br>エンティティクラスに属性を付与"]
    B --> C["3. Fluent API<br>OnModelCreating で明示的に構成"]
段階例特徴
規約Id または <型名>Id という名前のプロパティが主キーになる記述不要。既定の動作
データ注釈[MaxLength(200)]、[Required]エンティティクラスを見れば制約が分かる
Fluent APImodelBuilder.Entity<Blog>().Property(b => b.Name).HasMaxLength(200)最も表現力が高く、エンティティクラスを変更せずに構成できる

データ注釈による例です。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public class Blog
{
public int Id { get; set; }
[MaxLength(200)]
public required string Name { get; set; }
[MaxLength(2000)]
public required string Url { get; set; }
[Column(TypeName = "datetimeoffset(0)")]
public DateTimeOffset CreatedAt { get; set; }
}

同じ構成を Fluent API で書くと次のようになります。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>(entity =>
{
entity.ToTable("Blogs");
entity.HasKey(b => b.Id);
entity.Property(b => b.Name)
.IsRequired()
.HasMaxLength(200);
entity.Property(b => b.Url)
.IsRequired()
.HasMaxLength(2000);
entity.Property(b => b.CreatedAt)
.HasColumnType("datetimeoffset(0)");
entity.HasIndex(b => b.Url).IsUnique();
});
}

IEntityTypeConfiguration による構成の分割

Section titled “IEntityTypeConfiguration による構成の分割”

エンティティが増えると OnModelCreating が肥大化します。エンティティごとに IEntityTypeConfiguration<T> を実装したクラスへ切り出せます。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using BloggingApi.Models;
namespace BloggingApi.Data.Configurations;
public class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
public void Configure(EntityTypeBuilder<Blog> builder)
{
builder.ToTable("Blogs");
builder.HasKey(b => b.Id);
builder.Property(b => b.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(b => b.Url)
.IsRequired()
.HasMaxLength(2000);
builder.HasIndex(b => b.Url).IsUnique();
builder.HasMany(b => b.Posts)
.WithOne(p => p.Blog)
.HasForeignKey(p => p.BlogId)
.OnDelete(DeleteBehavior.Cascade);
}
}

アセンブリ内のすべての構成クラスをまとめて適用します。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(BloggingContext).Assembly);
}

同じ DbContext 型から複数のモデルを作る

Section titled “同じ DbContext 型から複数のモデルを作る”

OnModelCreating の中でコンテキストのプロパティを見て、モデルの構築内容を切り替えたくなることがあります。ところが公式ドキュメントが説明しているとおり、EF はモデルを一度だけ構築し、性能のために結果をキャッシュします。 そのため、素直に書いても切り替わりません。

次は、同じ DbContext 型でプロパティだけを変えた 2 つのインスタンスの確認例です。2 つ目も 1 つ目と同じモデルを使うことを確認できています。

ファクトリーなし UseIntProperty=true → IntValue あり
ファクトリーなし UseIntProperty=false → IntValue あり(切り替わっていない)

モデルのキャッシュキーは IModelCacheKeyFactory サービスが生成します。既定の実装はコンテキストの型だけをキーにするため、同じ型からは 1 つのモデルしか作られません。切り替えたい場合は、モデルに影響する変数をすべて含んだキーを返す実装に差し替えます。

以下は本編の BloggingContext とは別の、モデル切り替えの検証用定義です。.NET 10 の検証用プロジェクトへ dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.11 でプロバイダーを追加し、DynamicModelSample 名前空間の 1 つのファイルとして配置します。UseIntProperty に応じて Value または IntValue をモデルから外します。この比較はモデルの構築結果を調べるもので、データベースのテーブル作成は不要です。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace DynamicModelSample;
public class ConfigurableEntity
{
public int Id { get; set; }
public string? Value { get; set; }
public int IntValue { get; set; }
}
public class DynamicContext(bool useIntProperty, bool replaceFactory) : DbContext
{
public bool UseIntProperty { get; } = useIntProperty;
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=:memory:");
if (replaceFactory)
options.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
if (UseIntProperty)
modelBuilder.Entity<ConfigurableEntity>().Ignore(e => e.Value);
else
modelBuilder.Entity<ConfigurableEntity>().Ignore(e => e.IntValue);
}
}
public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is DynamicContext dynamicContext
? (context.GetType(), dynamicContext.UseIntProperty, designTime)
: (object)context.GetType();
}

OnConfiguring と OnModelCreating は、いずれも上の DynamicContext のメソッドです。new DynamicContext(useIntProperty: true, replaceFactory: false) と、useIntProperty だけを false にしたインスタンスが、上の既定キャッシュによる比較に対応します。replaceFactory: true にすると、OnConfiguring 内の ReplaceService が適用されます。

キャッシュキーの生成処理を差し替えた確認例では、次のように別々のモデルが得られています。

UseIntProperty=true → Value なし / IntValue: Int32
UseIntProperty=false → Value: String / IntValue なし

エンティティの等価性はオーバーライドしない

Section titled “エンティティの等価性はオーバーライドしない”

EF Core はエンティティのインスタンスを比較するときに参照等価性を使います。エンティティ型が Equals をオーバーライドしていても、EF Core 自身の比較は変わりません。

ただし 1 か所だけ影響が出ます。コレクションナビゲーションが参照等価性ではなく上書きされた等価性を使う場合、別々のインスタンスが同じものとして扱われてしまいます。C# の record を HashSet<T> のナビゲーションと組み合わせると、これが起こります。

public class Blog
{
public int Id { get; set; }
// record は値等価性を持つため、HashSet が別インスタンスを同じものと判定する
public ICollection<Post> Posts { get; set; } = new HashSet<Post>();
}
public record Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int BlogId { get; set; }
}

SQL Server 2022 を使うこの確認例では、値の同じ Post を 2 つ追加しても Posts.Count は 1、保存後の行も 1 件です。コレクションへの追加時に同じ値と扱われるため、2 件として保持されません。

公式ドキュメントはエンティティの等価性をオーバーライドしないことを推奨しています。どうしても使う場合は、コレクションナビゲーションに参照等価性を強制してください。.NET 5 以降は ReferenceEqualityComparer が BCL に含まれています。

public ICollection<Post> Posts { get; set; }
= new HashSet<Post>(ReferenceEqualityComparer.Instance);

同じ条件で比較子だけを差し替えた確認例では、Posts.Count と保存後の行数がともに 2 であることを確認できています。

組み上がったモデルを確認する

Section titled “組み上がったモデルを確認する”

規約・データ注釈・Fluent API が最終的にどう解釈されたのかは、モデルのデバッグビューで確認できます。「設定したはずの HasMaxLength が効いていない」「規約が張ったインデックスがどれか分からない」といったときに、推測せずに済みます。

Console.WriteLine(context.Model.ToDebugString());

次は、Blog と Post の単純なモデルの出力例です。

Model:
EntityType: Blog
Properties:
Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
Name (string) Required MaxLength(200)
Navigations:
Posts (List<Post>) Collection ToDependent Post Inverse: Blog
Keys:
Id PK
EntityType: Post
Properties:
Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
BlogId (int) Required FK Index
Title (string) Required
Foreign keys:
Post {'BlogId'} -> Blog {'Id'} Required Cascade ToDependent: Posts ToPrincipal: Blog
Indexes:
BlogId

キー、外部キー、削除時の動作 (Cascade)、規約が自動で張ったインデックス (BlogId) まで一覧できます。

プロバイダー固有のメタデータまで見たい場合は、長い形式を指定します。

Console.WriteLine(context.Model.ToDebugString(MetadataDebugStringOptions.LongDefault));

長い形式では、短い形式の情報に加えて注釈も表示されます。この確認例でも、モデルのカスタム注釈は LongDefault に表示され、短い形式には含まれていません。

Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
Annotations:
SqlServer:ValueGenerationStrategy: IdentityColumn
Name (string) Required MaxLength(200)
Annotations:
MaxLength: 200
SqlServer:ValueGenerationStrategy: None
Annotations:
Relational:TableName: Blogs

IdentityColumn が選ばれていること、テーブル名が Blogs に決まっていることまで分かります。MetadataDebugStringOptions は Microsoft.EntityFrameworkCore.Infrastructure 名前空間にあります。


EF Core は、ナビゲーションプロパティと外部キープロパティの命名規約からリレーションシップを推論します。明示的に指定する場合は Fluent API を使います。

関係Fluent API例
1 対多HasMany(...).WithOne(...)Blog 1 件に Post が複数
1 対 1HasOne(...).WithOne(...)Blog 1 件に対して BlogImage は最大 1 件。必須・任意の構成は別途確認する
多対多HasMany(...).WithMany(...)Post と Tag

多対多は結合エンティティを定義しなくても構成できます。

public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public List<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Post> Posts { get; set; } = [];
}

結合テーブルの名前を明示したい場合は、UsingEntity で指定します。指定しない場合、EF Core は規約に従って PostTag という名前のテーブルを生成します。

modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(join => join.ToTable("PostTags"));

このとき生成される結合テーブルの列名は、ナビゲーションプロパティの名前から作られます。次は、上の明示設定を適用した SQL Server 向けの生成 DDL の確認例です。結合テーブル名と制約名には PostTags が使われます。

CREATE TABLE [PostTags] (
[PostsId] int NOT NULL,
[TagsId] int NOT NULL,
CONSTRAINT [PK_PostTags] PRIMARY KEY ([PostsId], [TagsId]),
CONSTRAINT [FK_PostTags_Posts_PostsId] FOREIGN KEY ([PostsId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PostTags_Tag_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tag] ([Id]) ON DELETE CASCADE
);

Post.Tags / Tag.Posts という複数形のナビゲーション名から PostsId / TagsId になっている点に注意してください。この結合エンティティには対応する CLR 型がなく、共有型エンティティ (shared-type entity type) として扱われます。

結合テーブルに情報を持たせる

Section titled “結合テーブルに情報を持たせる”

「いつタグ付けしたか」のような、関連そのものに紐づく情報を結合テーブルに持たせたい場合は、結合エンティティ用のクラスを定義して UsingEntity<T> に渡します。この追加情報を ペイロード (payload) と呼びます。

public class PostTag
{
public int PostId { get; set; }
public int TagId { get; set; }
public DateTime TaggedOn { get; set; } // ペイロード
public string Note { get; set; } = ""; // ペイロード
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity<PostTag>(
j => j.Property(e => e.TaggedOn).HasDefaultValueSql("GETUTCDATE()"));
}

クラスを定義すると、外部キーの列名がエンティティ型の名前から作られるようになります。SQL Server での DDL は次のとおりです。

CREATE TABLE [PostTag] (
[PostId] int NOT NULL,
[TagId] int NOT NULL,
[TaggedOn] datetime2 NOT NULL DEFAULT (GETUTCDATE()),
[Note] nvarchar(max) NOT NULL,
CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagId]),
...
);

スキップナビゲーションとペイロードの関係

Section titled “スキップナビゲーションとペイロードの関係”

Post.Tags のように、結合エンティティを飛び越えて相手側を直接指すナビゲーションを スキップナビゲーション (skip navigation) と呼びます。結合エンティティへのナビゲーション(Post.PostTags)は、スキップナビゲーションと併存できます。このモデルでも、Include(x => x.Tags) と Include(x => x.PostTags) を同じクエリで使えることを確認できています。

公式のペイロードの説明は、結合エンティティへのナビゲーションによる追加情報の読み書きと、タイムスタンプなどの生成値の構成を紹介しています。この例の Note は結合エンティティに明示的に設定し、TaggedOn はデータベースの既定値で生成するという違いがあります。

post.Tags.Add(tag);
await context.SaveChangesAsync();

上の操作の確認結果では、結合行が 1 行挿入され、GETUTCDATE() を構成した TaggedOn に値が入り、Note は初期値の空文字のままです。

生成値にできないペイロードを設定する方法は 2 つあります。1 つは結合エンティティを自分で追加することです。

context.Add(new PostTag { PostId = post.Id, TagId = tag.Id, Note = "手動で設定" });
await context.SaveChangesAsync();

もう 1 つは、スキップナビゲーションで関連付けたあとに DetectChanges() を呼び、EF Core が作った結合エンティティを Find で取り出して書き換える方法です。公式ドキュメントが示している手順です。

post.Tags.Add(tag);
// これを呼ぶと、この時点で結合エンティティのインスタンスが作られる
context.ChangeTracker.DetectChanges();
var joinEntity = await context.Set<PostTag>().FindAsync(post.Id, tag.Id);
joinEntity!.Note = "手動で設定";
await context.SaveChangesAsync();

SQL Server 2022 の確認例では、DetectChanges() 後の Find による結合エンティティの取得と、Note の指定値の保存を確認できています。TaggedOn にはデータベースの既定値が格納されています。

削除時の動作は OnDelete で指定します。DeleteBehavior は EF Core が追跡中の子に対して行うことと、データベースに作られる外部キー制約の 2 つを同時に決めます。

次の表の EF Core 側の動作は、外部キーが null 許容の任意リレーションシップで、子を追跡したまま親を削除する場合です。必須リレーションシップや子を読み込んでいない場合とは区別してください(公式の条件別一覧)。

DeleteBehavior上記の条件で EF Core が子にすることSQL Server に作られる制約
Cascade子も削除するON DELETE CASCADE
Restrict子の外部キーを NULL にするON DELETE NO ACTION
NoAction子の外部キーを NULL にする指定なし(データベース既定)
SetNull子の外部キーを NULL にするON DELETE SET NULL
ClientSetNull子の外部キーを NULL にする指定なし(データベース既定)
ClientCascade子も削除する指定なし(データベース既定)
ClientNoAction子の外部キーを変更しない指定なし(データベース既定)

Client で始まる 3 つはデータベース側にカスケード動作を設定せず、EF Core の追跡に作用します。上の表の制約は SQL Server 2022 向けに GenerateCreateScript() で実測したものです。SetNull は外部キー列が null 非許容だとデータベース作成時に失敗します。

NoAction の確認例(EF Core 10.0.11 / SQLite、子 2 件を追跡して親を削除)では、任意 FK は親だけが削除され、子の FK は NULL です。一方、CLR の FK 型が int? でも IsRequired(true) でモデルと列を必須にする条件では、保存は失敗し親と子が残ることを確認できています。NoAction は「EF Core が何もしない」という意味ではなく、必須 FK にも NULL 更新を適用できるわけではありません。 例外型も構成に依存するため、一律には扱わないでください。

自己参照の多対多は「対称」にならない

Section titled “自己参照の多対多は「対称」にならない”

同じエンティティ型を多対多の両端に使うこともできます。公式ドキュメントが「自己参照 (self-referencing) リレーションシップ」と呼んでいる構成です。

public class Person
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Person> Friends { get; } = new();
public List<Person> FriendOf { get; } = new();
}
modelBuilder.Entity<Person>().HasMany(p => p.Friends).WithMany(p => p.FriendOf);

このモデルの SQL Server での確認結果では、結合テーブルは PersonPerson で、2 つの外部キーがともに People を指しています。

CREATE TABLE [PersonPerson] (
[FriendOfId] int NOT NULL,
[FriendsId] int NOT NULL,
CONSTRAINT [PK_PersonPerson] PRIMARY KEY ([FriendOfId], [FriendsId]),
CONSTRAINT [FK_PersonPerson_People_FriendOfId] FOREIGN KEY ([FriendOfId]) REFERENCES [People] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PersonPerson_People_FriendsId] FOREIGN KEY ([FriendsId]) REFERENCES [People] ([Id])
);

同じ 2 つの型のあいだにリレーションシップが 2 つあると構築に失敗する

Section titled “同じ 2 つの型のあいだにリレーションシップが 2 つあると構築に失敗する”

ブログに「記事の一覧」と「注目記事」の 2 つを持たせるように、同じ 2 つの型のあいだにリレーションシップが 2 本あるモデルは珍しくありません。ところが、これを規約任せにするとモデルの構築そのものが失敗します。

public class Blog
{
public int Id { get; set; }
public List<Post> Posts { get; } = new(); // 記事の一覧
public int? FeaturedPostId { get; set; }
public Post? FeaturedPost { get; set; } // 注目記事
}
public class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public Blog? Blog { get; set; }
}

公式ドキュメントは「何も構成しないと、EF の規約は 2 つの型のあいだのどのナビゲーションどうしを対にすべきかを判断できない」と説明しています。次は、このモデルを構成せずに使う場合の例外の確認例です。

System.InvalidOperationException: Unable to determine the relationship represented by
navigation 'Blog.FeaturedPost' of type 'Post'. Either manually configure the relationship,
or ignore this property using the '[NotMapped]' attribute or by using
'EntityTypeBuilder.Ignore' in 'OnModelCreating'.

Fluent API では、2 本のリレーションシップを個別に書けば解決します。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasMany(b => b.Posts).WithOne(p => p.Blog).HasForeignKey(p => p.BlogId);
modelBuilder.Entity<Blog>()
.HasOne(b => b.FeaturedPost).WithOne().HasForeignKey<Blog>(b => b.FeaturedPostId);
}

データ注釈で書く場合は、対にしたい相手のナビゲーション名を [InverseProperty] で指定します。上のモデルなら Blog.Posts の相手が Post.Blog であることを教えます。

[InverseProperty("Blog")]
public List<Post> Posts { get; } = new();

どちらの構成でも、Post.BlogId → Blog と Blog.FeaturedPostId → Post の 2 本の外部キーを確認できています。

[Required] は付ける場所で効果が変わる

Section titled “[Required] は付ける場所で効果が変わる”

リレーションシップを必須にする(外部キーを NOT NULL にする)ために [Required] を使うことがあります。ただし付ける場所によって、効くか黙って無視されるかが変わります。

公式ドキュメントは、従属側(外部キーを持つ側)のナビゲーションに付けた場合は外部キーが NOT NULL になり、主体側のナビゲーションに付けた場合は効果がないと明記しています。次は、その確認例です。

[Required] を付けた場所外部キーの NULL 許容
従属側のナビゲーション(Post.Blog)NOT NULL になる
主体側のナビゲーション(Blog.Posts)NULL のまま(無視される)

この確認例では、主体側コレクションに [Required] を付けても外部キーは任意のままです。Debug レベルには RequiredAttributeOnCollection の診断が出ています。これは Warning レベルのログではないため、通常のログ設定では見落とし得ます。属性の見た目だけでなく、生成モデルの外部キーの必須性を確認してください。

カスケード削除は「子を読み込んでいるか」で主体が変わる

Section titled “カスケード削除は「子を読み込んでいるか」で主体が変わる”

公式のカスケード削除の説明では、必須リレーションシップの既定は Cascade、任意リレーションシップの既定は ClientSetNull です。明示的な構成がない場合、次の外部キー型はそれぞれの関係に対応します。

リレーションシップ外部キー既定の DeleteBehavior
必須intCascade
任意int?ClientSetNull

公式は、追跡中の子には EF Core がカスケード削除を適用し、未読込の子にはデータベースの ON DELETE CASCADE が必要と説明しています。既定の Cascade を使う必須リレーションシップで、親 1 件・子 2 件を削除する確認例は次のとおりです。

操作SaveChangesAsync の戻り値削除の主体
Include して親を Remove3EF Core(子を Deleted にして削除)
Include せずに親を Remove1データベース(ON DELETE CASCADE)

この例では、Include した場合は Remove 直後の子の状態が Deleted で、保存の戻り値は 3 です。未読込の場合は EF Core が親の DELETE を発行し、子の削除はデータベース側の外部キー制約が受け持つことを確認できています。

カスケード削除のタイミングを制御する

Section titled “カスケード削除のタイミングを制御する”

追跡済みエンティティに対してカスケードの動作がいつ行われるかは、ChangeTracker.CascadeDeleteTiming と ChangeTracker.DeleteOrphansTiming で制御できます。指定できる値は公式 API リファレンスによると次の 3 つです。

値意味
Immediate主体・親エンティティが変更されたらすぐに、依存・子エンティティへカスケードの動作を行う
OnSaveChangesSaveChanges の一部としてカスケードの動作を行う
Never自動的にはカスケードの動作を行わず、明示的な呼び出しで発生させる

公式のタイミングの説明では、既定のカスケード削除は親が Deleted になった時点、孤児の削除は関係変更を検出した時点で行われます。次は CascadeDeleteTiming を変え、追跡中の親に Remove を呼んだ直後の子の状態を確認した結果です。データベースへ DELETE を発行する時点ではありません。

既定 (Immediate): Remove 直後の子の状態 = Deleted, Deleted
OnSaveChanges: Remove 直後の子の状態 = Unchanged, Unchanged
Never: Remove 直後の子の状態 = Unchanged

参照を切っても子は削除されない

Section titled “参照を切っても子は削除されない”

親子関係を切るときに、Remove ではなくコレクションから外す書き方をすることがあります。

var blog = await db.Blogs.Include(b => b.Posts).SingleAsync(b => b.Id == 1);
blog.Posts.Clear();
await db.SaveChangesAsync();

公式の任意関係の切断の説明では、既定の構成で子をコレクションから外すと、子を削除せずに外部キーを null にします。SQL Server でのこの確認例でも、Clear() 後の保存 SQL は UPDATE [Posts] SET [BlogId] = @p0 で、Post は 1 件残り、BlogId は NULL です。

公式の EF Core 7 の変更履歴は、Cascade を構成した任意の関係でも、関係の切断では子を削除せず、親の削除ではカスケード削除することを説明しています。EF Core 10 の対照でも、nullable な外部キーに Cascade を明示した構成で、Clear() 後には子と null の外部キーが残り、親の削除では子も削除されることを確認できています。

必須リレーションシップで既定の Cascade を使う場合は、関係を切ると孤児として子が削除対象になります。また、任意リレーションシップで親を Remove したときに子も削除するには、Cascade などの構成が必要です。任意関係の既定の ClientSetNull とは区別してください。

db.Blogs.Remove(blog); // → Post も削除される(構成に従う)
blog.Posts.Clear(); // → Post は残り、BlogId が NULL になる

子を確実に消したいのであれば、明示的に削除します。

db.Posts.RemoveRange(blog.Posts);
await db.SaveChangesAsync();

SQL Server では循環するカスケードを作れない

Section titled “SQL Server では循環するカスケードを作れない”

必須リレーションシップは既定でカスケード削除になります。これらの外部キーが循環または複数のカスケード経路を作ると、SQL Server はその制約の作成を拒否します。次は、人物から投稿へ直接たどる経路と、ブログを経由する経路が重なるモデルでの、データベース作成時の例外の確認例です。

Introducing FOREIGN KEY constraint 'FK_Posts_People_AuthorId' on table 'Posts'
may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or
ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.

公式ドキュメントは対処として次の 2 つを挙げています。

  1. いずれかのリレーションシップをカスケード削除しない設定に変える(外部キーを NULL 許容にするなど)
  2. データベース側のカスケードを外したうえで、削除前に子エンティティをすべて読み込み、EF Core にカスケードを実行させる

列の型とプロパティの型が一致しない場合は 値の変換 (Value Conversion) を使います。列挙型を文字列として保存する例です。

この節の Post は、状態・コメントを扱う独立した検証用定義です。本編の Post への差分ではありません。付録4の一括更新・一括削除で使用する場合は、ここに示す型と後掲の Author / Address を PostStatusSample 名前空間へまとめます。

public enum PostStatus
{
Draft,
Published,
Archived
}
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public int BlogId { get; set; }
public PostStatus Status { get; set; }
// 一括更新・一括削除、関連データの読み込みで使う
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ArchivedAt { get; set; }
public List<Comment> Comments { get; set; } = [];
}
public class Comment
{
public int Id { get; set; }
public required string Body { get; set; }
public int PostId { get; set; }
public Post? Post { get; set; }
public int AuthorId { get; set; }
public Author? Author { get; set; }
}

IEntityTypeConfiguration<Post> の Configure メソッド(引数名 builder)の中で、次のように構成します。

builder.Property(p => p.Status)
.HasConversion<string>()
.HasMaxLength(20);

これにより Status 列は int ではなく "Draft" のような文字列として保存され、SQL を直接見たときにも意味が分かるようになります。

エンティティの一部を別のテーブルや別の型として切り出したい場合は 所有型 (Owned Entity Type) を使います。所有型は所有側のエンティティの一部として扱われますが、EF Core のモデル上では主キーを持ちます。 次の OwnsOne では、CLR 型にキープロパティを書かなくても、所有者のキーと同じ値を持つシャドウキーが構成されます。

modelBuilder.Entity<Author>()
.OwnsOne(a => a.Address);

住所のように、それ自体は識別子を持たず、親エンティティのテーブルに展開したい値の集まりには 複合型 (Complex Type) を使います。

public class Address
{
public required string PostalCode { get; set; }
public required string Prefecture { get; set; }
public required string Line1 { get; set; }
}
public class Author
{
public int Id { get; set; }
public required string Name { get; set; }
public required Address Address { get; set; }
}

複合型は所有型と違って独立したエンティティにならないため、OwnsOne ではなく ComplexProperty で構成します。

modelBuilder.Entity<Author>()
.ComplexProperty(a => a.Address);

これにより Authors テーブルに Address_PostalCode、Address_Prefecture、Address_Line1 という列が作られます。

JSON にマッピングした型が列挙型 (enum) のプロパティを持つ場合、EF Core 8 以降は既定で数値として保存されます。EF Core 7 では文字列でした。

public enum Status { Pending, Shipped, Delivered }
{"Note":"出荷済み","Status":1}

上の JSON は、このモデルでの確認結果です。文字列で保存したい場合は、変換を明示的に構成します。

modelBuilder.Entity<Order>().OwnsOne(x => x.Detail, b =>
{
b.ToJson();
b.Property(d => d.Status).HasConversion<string>();
});
{"Note":"出荷済み","Status":"Shipped"}

公式ドキュメントは、この変更の理由を「EF Core は昔からリレーショナルデータベースの列に対して列挙型を数値でマッピングしてきた。JSON の値と列やパラメーターの値が突き合わされるクエリを EF Core がサポートしている以上、両者の表現が一致していることが重要だから」と説明しています。

JSON コレクションに LINQ 演算子を使うときは追跡動作に注意する

Section titled “JSON コレクションに LINQ 演算子を使うときは追跡動作に注意する”

EF Core 9 の破壊的変更では、JSON にマップしたエンティティのコレクションへ直接 LINQ 演算子を適用するクエリと、AsNoTrackingWithIdentityResolution() の組み合わせが禁止されています。次の OrderBy と Take を使う射影が該当します。

この組み合わせは EF Core 8 までは許可されていましたが、ストリーミングされる JSON の入れ子の要素と、ID の解決に必要なキー値の扱いにより、誤った結果やデータの破損を招く可能性がありました。公式は、これを防ぐために EF Core 9 で制限を加えたと説明しています。

var blogs = await db.Blogs
.AsNoTrackingWithIdentityResolution()
.Select(b => new { Blog = b, Top = b.JsonPosts.OrderBy(p => p.Rating).Take(1).ToList() })
.ToListAsync();

次は、上のクエリで確認できている例外です。

InvalidOperationException: Projecting queryable operations on JSON collection is not
supported for 'NoTrackingWithIdentityResolution'.

公式は回避策として、通常の追跡クエリ、または ID の解決を行わない AsNoTracking() を案内しています。上の確認例でも AsNoTracking() での実行は成功しています。

EF Core 10 の JSON 列は json 型になる(Azure SQL の破壊的変更)

Section titled “EF Core 10 の JSON 列は json 型になる(Azure SQL の破壊的変更)”

OwnsMany(...).ToJson() の所有コレクションや string[] のプリミティブコレクションについて、公式の変更履歴は、EF Core 9 までの SQL Server プロバイダーが既定で nvarchar(max) 列を使用していたと説明しています。SQL Server 2025 上の EF Core 10 の確認例では、互換性レベル 160 の構成は nvarchar(max)、UseAzureSql の構成は json です。旧 EF Core の再実行ではなく、現行版の構成による違いの確認です。

EF Core 10 では、UseAzureSql を使うか、互換性レベル 170 以上を構成すると、既定で SQL Server の json データ型にマッピングされます。SQL Server 2022(互換性レベル 160)で ToJson() を使う所有型の確認例では、列は nvarchar(max) で、{"Author":"x","Tags":["t1","t2"]} の格納を確認できています。

次の Azure SQL Database / UseAzureSql の確認例は、JSON 所有型 Meta とベクトルを持つ別の Doc モデルによるものです。DDL と sys.columns の両方で Meta が json 型となり、Where(d => d.Meta.Author == "x") が実行できることを確認できています。前述の SQL Server 2022 のモデルとは、プロパティ構成が異なります。

-- Azure SQL に UseAzureSql で作成したときの実際の DDL
CREATE TABLE [Docs] (
[Id] int NOT NULL IDENTITY,
[Title] nvarchar(max) NOT NULL,
[Meta] json NOT NULL,
CONSTRAINT [PK_Docs] PRIMARY KEY ([Id])
);
-- EF Core 9 まで
[Tags] nvarchar(max)
-- EF Core 10(UseAzureSql または互換性レベル 170 以上)
[Tags] json

次は、複合型を ToJson() でマップした Details に対する Where(b => b.Details.Viewers > 3) の確認例です。同じモデル・同じ LINQ でも、以下の構成では生成 SQL が異なることを確認できています。

実行環境生成された SQL
SQL Server 2022(UseSqlServer)WHERE CAST(JSON_VALUE([b].[Details], '$.Viewers') AS int) > 3
Azure SQL Database(UseAzureSql)WHERE JSON_VALUE([b].[Details], '$.Viewers' RETURNING int) > 3

json 型では JSON_VALUE() の RETURNING 句が使われ、CAST が不要になります。生成される SQL を文字列として比較するスナップショットテストを書いている場合は、接続先によって結果が変わる点に注意してください。

.NET 6 で追加された DateOnly と TimeOnly は、日付だけ・時刻だけを表す型です。次は SQL Server 2022 と SQLite の GenerateCreateScript() で確認できている列型です。

.NET の型SQL Server の列型SQLite の列型
DateOnlydateTEXT
TimeOnlytimeTEXT
DateTimedatetime2TEXT
TimeSpantimeTEXT
DateTimeOffsetdatetimeoffsetTEXT

TimeSpan も SQL Server では time になりますが、意味が違います。公式ドキュメントは、DateTime は使われない時刻成分を含むため date にマッピングすると混乱を招き、TimeSpan は「時刻」ではなく時間の間隔(日数を含むこともある)を表すと説明しています。日付だけ・時刻だけを扱う列には DateOnly / TimeOnly を使ってください。

エンティティに継承関係がある場合、EF Core は 3 つのマッピング方法を提供します。既定は TPH (table-per-hierarchy) で、階層全体を 1 つのテーブルに格納し、行がどの型かを示す 識別子列 (discriminator) を暗黙的に追加します。

public abstract class Payment
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
public class CreditCardPayment : Payment
{
public string CardNumber { get; set; } = "";
}
public class BankTransferPayment : Payment
{
public string BankName { get; set; } = "";
}

TPH では次の DDL が生成されます(SQL Server プロバイダーでの実測)。

CREATE TABLE [Payments] (
[Id] int NOT NULL IDENTITY,
[Amount] decimal(18,2) NOT NULL,
[Discriminator] nvarchar(21) NOT NULL,
[BankName] nvarchar(max) NULL,
[CardNumber] nvarchar(max) NULL,
CONSTRAINT [PK_Payments] PRIMARY KEY ([Id])
);

派生型だけが持つ列は自動的に NULL 許容になります。公式ドキュメントにも「TPH マッピングを使う場合、データベースの列は必要に応じて自動的に NULL 許容になる」と記載されています。

UseTptMappingStrategy() を指定すると TPT (table-per-type) になり、基底型と派生型がそれぞれのテーブルに分かれます。派生テーブルの主キーは基底テーブルへの外部キーを兼ねます。SQL Server での DDL は次のとおりです。

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<Payment>().UseTptMappingStrategy();
CREATE TABLE [Payments] (
[Id] int NOT NULL IDENTITY,
[Amount] decimal(18,2) NOT NULL,
CONSTRAINT [PK_Payments] PRIMARY KEY ([Id])
);
CREATE TABLE [CreditCardPayments] (
[Id] int NOT NULL,
[CardNumber] nvarchar(max) NOT NULL,
CONSTRAINT [PK_CreditCardPayments] PRIMARY KEY ([Id]),
CONSTRAINT [FK_CreditCardPayments_Payments_Id] FOREIGN KEY ([Id])
REFERENCES [Payments] ([Id]) ON DELETE CASCADE
);

UseTpcMappingStrategy() による TPC (table-per-concrete-type) では、具象型ごとに独立したテーブルを作り、それぞれが基底型の列も持ちます。主キーが階層全体で一意になるよう、EF Core は共有シーケンスを作成します(シーケンスの構成方法は付録2の「シーケンスによる採番」を参照)。SQL Server での DDL は次のとおりです。

CREATE SEQUENCE [PaymentSequence] START WITH 1 INCREMENT BY 1 NO CYCLE;
CREATE TABLE [CreditCardPayments] (
[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [PaymentSequence]),
[Amount] decimal(18,2) NOT NULL,
[CardNumber] nvarchar(max) NOT NULL,
CONSTRAINT [PK_CreditCardPayments] PRIMARY KEY ([Id])
);
方式テーブル数特徴
TPH(既定)1多くの用途で有力な既定の選択肢。派生型の情報を読むためのテーブル結合が不要。性能はモデルとクエリ次第。派生型固有の列は NULL 許容になる
TPT型の数だけ正規化されるが、取得のたびに結合が必要
TPC具象型の数だけ結合が不要。公式は「TPT で起きがちな性能問題に対処するもの」と説明

派生型を絞り込むクエリでは、EF Core が識別子列の条件を自動的に付け加えます。SQL Server で実測した SQL は次のとおりです。

-- context.Payments.OfType<CreditCardPayment>() が生成する SQL
SELECT [p].[Id], [p].[Amount], [p].[Discriminator], [p].[CardNumber]
FROM [Payments] AS [p]
WHERE [p].[Discriminator] = N'CreditCardPayment'

識別子列には最大長が設定される

Section titled “識別子列には最大長が設定される”

TPH の識別子列は、EF Core 8 以降、既知の識別子値をすべて収められる最大長で作られます。それより前は nvarchar(max) でした。次は、型名の長さを変えた場合の生成 DDL の確認例です。

識別子の値: Animal, Cat, Dog
→ [Discriminator] nvarchar(8) NOT NULL
識別子の値: Car, Vehicle, VeryLongVehicleTypeNameForTesting
→ [Discriminator] nvarchar(34) NOT NULL

インデックスを張れる、格納効率がよいといった利点がありますが、公式ドキュメントは注意点も挙げています。既存のデータベースをアップグレードすると AlterColumn が生成され、識別子列がインデックスなどで制約されている場合には失敗することがあるという点です。既存のマイグレーションを持つプロジェクトでは、生成された AlterColumn を必ず目視で確認してください。

長さを固定したい場合は明示的に構成できます。

modelBuilder.Entity<Animal>()
.Property<string>("Discriminator")
.HasMaxLength(50);