コンテンツにスキップ

付録 EF Core 3:マイグレーションの詳細とクエリ

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。マイグレーションの詳細な扱い方と、クエリの制御、SQL を直接扱う方法を扱います。

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

第8章のほかの付録


  1. マイグレーションの詳細
  2. クエリの制御
  3. SQL を直接扱う
  4. 参考ドキュメント

生成されたマイグレーションを読む

Section titled “生成されたマイグレーションを読む”

生成されるマイグレーションは通常の C# コードです。内容を確認し、必要なら手を入れられます。

public partial class AddPostPublishedAt : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "PublishedAt",
table: "Posts",
type: "datetimeoffset",
nullable: true);
migrationBuilder.Sql("UPDATE [Posts] SET [PublishedAt] = [CreatedAt] WHERE [PublishedAt] IS NULL;");
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "PublishedAt",
table: "Posts",
type: "datetimeoffset",
nullable: false,
defaultValue: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
oldClrType: typeof(DateTimeOffset),
oldType: "datetimeoffset",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Posts_PublishedAt",
table: "Posts",
column: "PublishedAt");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "IX_Posts_PublishedAt", table: "Posts");
migrationBuilder.DropColumn(name: "PublishedAt", table: "Posts");
}
}

Up が適用時、Down がロールバック時の処理です。この例は、既存の CreatedAt が非 NULL の datetimeoffset 列であることを前提に、生成されたコードへデータ移行を加えたものです。公式のデータ移行の手順に従い、NULL 許容で追加 → Sql で既存データを埋める → 非 NULL 化の順に処理します。最初から非 NULL と既定値を指定して追加すると、既存行にも既定値が入り、WHERE [PublishedAt] IS NULL に一致する行がなくなります。

上の defaultValue は、移行後に列の値を省略して挿入する行に対する既定値です。既存行はこの固定値ではなく、それぞれの CreatedAt で埋めます。列の既定値を維持する場合は、モデル側の HasDefaultValue も同じ値に合わせます。

EF Core 10.0.11 でこの Up から生成した SQL を Azure SQL Database の既存 2 行に適用した確認例でも、それぞれの CreatedAt が PublishedAt に引き継がれています。移行後の明示的な NULL 挿入は制約違反になり、PublishedAt を省略した新規行には指定した既定値が入ることを確認できています。

列に既定値を設定すると、SQL Server 側には 既定値制約 (default constraint) が作られます。名前を指定しない場合、制約名はデータベースが自動生成します。次は、名前を指定しないテーブルを sys.default_constraints で確認した例です。

DF__Posts__CreatedDa__49C3F6B7
DF__Posts__Views__4AB81AF0

自動生成された名前は固定名として扱わず、対象データベースで確認する必要があります。名前が分からないと、後から ALTER TABLE ... DROP CONSTRAINT で外すのが面倒になります。

EF Core 10 では、HasDefaultValueSql / HasDefaultValue の第 2 引数で制約名を指定できるようになりました。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.Property(p => p.CreatedDate)
.HasDefaultValueSql("GETDATE()", "DF_Post_CreatedDate");
}

生成されるマイグレーションには注釈として制約名が入り、SQL Server 向けの DDL では CONSTRAINT 句として出力されます。

CREATE TABLE [Posts] (
[Id] int NOT NULL IDENTITY,
[CreatedDate] datetime2 NOT NULL CONSTRAINT [DF_Post_CreatedDate] DEFAULT (GETDATE()),
[Views] int NOT NULL DEFAULT 0,
CONSTRAINT [PK_Posts] PRIMARY KEY ([Id])
);

名前を指定しなかった Views 列には CONSTRAINT 句が付いていない点に注目してください。列ごとに名前を付けて回るのが面倒な場合は、UseNamedDefaultConstraints() でモデル全体の既定値制約に EF Core が自動で名前を付けられます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.UseNamedDefaultConstraints();
}

これを有効にした同じモデルの確認例では、SQL Server 向けの DDL に両方の既定値制約の名前が含まれています。

[CreatedDate] datetime2 NOT NULL CONSTRAINT [DF_Posts_CreatedDate] DEFAULT (GETDATE()),
[Views] int NOT NULL CONSTRAINT [DF_Posts_Views] DEFAULT 0,

公式ドキュメントは「ツールパッケージのバージョンは、常にランタイムパッケージのメジャーバージョンに合わせて使用する」と明記しています。バージョンがずれていても即座に失敗するとは限らないため、気づかないまま運用してしまいがちです。

次は、EF Core 10.0.11 のランタイムに対して EF Core 9.0.0 の dotnet-ef で migrations add を実行する確認例の警告です。この条件で生成が成功していても、メジャーバージョンを合わせる公式の指針は変わりません。

The Entity Framework tools version '9.0.0' is older than that of the runtime '10.0.11'.
Update the tools for the latest features and bug fixes.

「動いてしまう」ため放置されやすいのですが、古いツールは新しいバージョンで追加された機能や修正を知りません。この警告が出たら、そのままにせずツールを更新してください。

database update <Name> は「そこまで戻す」であって「1 つ戻す」ではない

Section titled “database update <Name> は「そこまで戻す」であって「1 つ戻す」ではない”

dotnet ef database update にマイグレーション名を渡すと、コマンド完了後にデータベースがその状態になるように動きます。公式ドキュメントは「データベースが指定より新しいマイグレーションにある場合、コマンドは対象より新しいマイグレーションをすべて Down の実行によって取り消す。古いマイグレーションを 1 つだけ順序外に適用するのではない」と警告しています。

次は、M1 / M2 / M3 を適用した SQL Server 2022 で M1 を指定する確認例です。2 件の取り消しを確認できています。

Reverting migration '20260908035812_M3'.
Reverting migration '20260908035806_M2'.
Done.
20260908035800_M1
20260908035806_M2 (Pending)
20260908035812_M3 (Pending)

「1 つだけ戻すつもりで古い名前を指定したら、間のマイグレーションまで全部 Down が走った」という事故になり得ます。Down はデータを失う操作を含むことがあるため、本番環境では実行前に必ず dotnet ef migrations list で現在地を確認してください。


DbContext が複数あるときは --context が必須

Section titled “DbContext が複数あるときは --context が必須”

公式の CLI オプションの説明では、コンテキストクラスが複数ある場合は --context が必須です。次は、2 つの DbContext があるプロジェクトで指定を省略したときの確認例です。

More than one DbContext was found. Specify which one to use.
Use the '-Context' parameter for PowerShell commands and the '--context' parameter for dotnet commands.

--context の候補は dotnet ef dbcontext list で確認できます。

Terminal window
dotnet ef dbcontext list
# Db
# AuditDb

マイグレーションを追加するときは、--context に加えて --output-dir も指定してください。指定しないと、すべての DbContext のマイグレーションが同じ Migrations フォルダーに混在します。

Terminal window
dotnet ef migrations add InitialCreate --context AuditDb --output-dir AuditMigrations

database update や migrations script など、他のコマンドでも同様に --context が必要です。


Visual Studio では、dotnet ef の代わりにパッケージマネージャーコンソールから PowerShell コマンドを使えます。また、複数のターゲットフレームワークを持つプロジェクトでは --framework の指定が必要です。

Visual Studio のパッケージマネージャーコンソールを使う場合

Visual Studio では、Microsoft.EntityFrameworkCore.Tools パッケージを追加すると、パッケージマネージャーコンソールから PowerShell コマンドを使用できます。

Terminal window
Install-Package Microsoft.EntityFrameworkCore.Tools

以降、本章で紹介する dotnet ef コマンドは、次のように読み替えられます。

.NET CLIパッケージマネージャーコンソール
dotnet ef migrations add <Name>Add-Migration <Name>
dotnet ef database updateUpdate-Database
dotnet ef migrations scriptScript-Migration
dotnet ef migrations removeRemove-Migration

マイグレーションを扱うコマンド以外にも、構成の確認やスキーマの出力に使えるサブコマンドがあります。

コマンド用途
dotnet ef dbcontext infoDbContext の型、プロバイダー、接続先を表示する
dotnet ef dbcontext listプロジェクト内の DbContext を一覧表示する
dotnet ef dbcontext scriptマイグレーションを介さず、モデルから直接 SQL スクリプトを生成する

dotnet ef dbcontext info は、設計時にどの接続文字列が使われているかを確認したいときに便利です(実測で確認)。

Type: Ctx
Provider name: Microsoft.EntityFrameworkCore.Sqlite
Database name: main
Data source: cli.db
Options: None

SQL スクリプトとマイグレーションバンドルを使い分ける

Section titled “SQL スクリプトとマイグレーションバンドルを使い分ける”

SQL スクリプトは、DBA によるレビューやアーカイブが必要な場合に適しています。

Terminal window
# 空のデータベースから最新までのスクリプトを生成
dotnet ef migrations script
# 冪等 (idempotent) なスクリプトをファイルに出力
dotnet ef migrations script --idempotent --output artifacts/migrations.sql
# 特定のマイグレーション間のスクリプトを生成
dotnet ef migrations script AddNewTables AddAuditTable

--idempotent を付けると、移行履歴を確認し、未適用のマイグレーションだけを実行するスクリプトが生成されます。同じ一連のマイグレーションで管理され、適用済みの位置が異なるデータベースに使えます。手作業によるスキーマ変更まで検出・修復する仕組みではないため、適用前の SQL レビューは必要です。

自動デプロイには マイグレーションバンドル が推奨されます。公式ドキュメントによれば、バンドルは CI で生成でき、実行時に .NET SDK も EF Core ツールもアプリケーションのソースコードも不要で、自己完結型にすれば .NET ランタイムすら不要な単一の実行可能ファイルです。EF Core のマイグレーションロックも機能します。

Terminal window
# .NET ランタイムがインストール済みの環境向け
dotnet ef migrations bundle --output efbundle
# .NET ランタイムごと同梱する(Linux x64 向け)
dotnet ef migrations bundle --self-contained --target-runtime linux-x64 --output efbundle

生成したバンドルは、デプロイ先で実行可能ファイルとして起動します。

Terminal window
# デプロイ先で実行
./efbundle --connection "$CONNECTION_STRING"

マイグレーションバンドルの対象ランタイムを指定する

Section titled “マイグレーションバンドルの対象ランタイムを指定する”

公式のバンドル用オプションでは、出力するバンドルの対象ランタイムは --target-runtime(短縮形 -r) で指定します。共通オプションの --runtime は、パッケージを復元する対象のランタイム識別子であり、バンドルの指定とは区別してください。

macOS から --target-runtime linux-x64 を指定した確認例では、Linux 向けの実行可能ファイル(ELF 64-bit)の生成と、そのバンドルによる SQL Server 2022 へのマイグレーション適用を確認できています。

モデルとマイグレーションのずれを検出する

Section titled “モデルとマイグレーションのずれを検出する”

エンティティを変更したのにマイグレーションを追加し忘れると、モデルとデータベースのスキーマがずれます。EF Core 9 以降、この状態で Migrate() / MigrateAsync() または dotnet ef database update を呼ぶと例外になります。 公式は影響度が「High」の破壊的変更として扱っています。

次は、Blog に Url を追加してマイグレーションを作らず、SQL Server 2022 に対して MigrateAsync() を呼ぶ確認例の例外です。

InvalidOperationException: An error was generated for warning
'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning':
The model for context 'PendContext' has pending changes.
Add a new migration before updating the database.

同じ状態で dotnet ef database update を実行した場合も、同じ警告 ID で失敗します。

DatabaseFacade.HasPendingModelChanges() で同じ判定をコードから行えます。公式は「マイグレーションの追加を忘れたときに失敗する単体テストを書くのに使える」と述べています。

connectionString には本編で設定した SQL Server の接続文字列を渡します。本編の BloggingContext は引数なしでは作成できないため、DbContextOptions<BloggingContext> を明示して構成します。

var options = new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(connectionString)
.Options;
using var db = new BloggingContext(options);
if (db.Database.HasPendingModelChanges())
{
throw new InvalidOperationException("マイグレーションが追加されていません。");
}

未適用マイグレーションとモデル差分を区別する

Section titled “未適用マイグレーションとモデル差分を区別する”

GetMigrations() は構成されたマイグレーションアセンブリ内の一覧、GetAppliedMigrationsAsync() は対象データベースの適用済み一覧、GetPendingMigrationsAsync() はそのアセンブリ内で対象データベースへ未適用の一覧を返します。公式のマイグレーションの一覧表示には、コードから取得する例もあります。

var allMigrations = db.Database.GetMigrations();
var appliedMigrations = await db.Database.GetAppliedMigrationsAsync();
var pendingMigrations = await db.Database.GetPendingMigrationsAsync();

次は、EF Core 10.0.11 / SQL Server 2022 でテーブル作成と列追加の 2 つのマイグレーションを使う確認例です。

状態全件適用済み未適用HasPendingModelChanges()
データベース未作成202false
最初の 1 件だけ適用211false
2 件とも適用220false
さらにモデルだけ変更し、マイグレーションは未追加220true

未適用が 0 件でも、マイグレーションの作成忘れがないとは限りません。 GetPendingMigrationsAsync() はまだマイグレーションになっていないモデル変更を検出しません。その判定には前述の HasPendingModelChanges() を使ってください。

dotnet ef migrations has-pending-model-changes はモデル変更の追加忘れを検出するコマンドです。EF Core 10.0.11 の公式実装では、変更があれば OperationException を発生させ、CLI が例外を終了コード 1 として返します。次の終了コードを確認できており、CI の判定に利用できます。終了コード 1 は他のエラーでも返るため、原因は出力メッセージで確認してください。

状態出力終了コード
保留中の変更ありChanges have been made to the model since the last migration. Add a new migration.1
保留中の変更なしNo changes have been made to the model since the last migration.0

同時にマイグレーションが走らないようにする

Section titled “同時にマイグレーションが走らないようにする”

起動時マイグレーションでいちばん怖いのは、複数のインスタンスが同時に起動してマイグレーションを二重に適用することです。コンテナーをスケールアウトした瞬間や、ローリングデプロイの最中に起こります。

公式ドキュメントによれば、EF Core 9 以降の Migrate() / MigrateAsync() は、マイグレーションを適用する前にデータベース全体のロックを自動で取得します。ロックはマイグレーションの実行中(シードコードの実行も含む)保持され、完了すると自動的に解放されます。ロックは dotnet ef database update、Update-Database、マイグレーションバンドル、実行時マイグレーションのいずれにも適用されます。SQL スクリプトは EF Core の外で適用されるため対象外です。

SQL Server ではセッションレベルのアプリケーションロックが使われます。次は、MigrateAsync() のログで確認できている取得・解放の SQL です。

DECLARE @result int;
EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive';
SELECT @result
-- (マイグレーション適用後)
DECLARE @result int;
EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session';
SELECT @result

次は、別の接続で同じリソース名の sp_getapplock を先に取得し、MigrateAsync() の開始から 6 秒後に解放する確認例です。

先行ロック取得: 戻り値 0
6.0 秒後にロックを解放した
MigrateAsync 完了まで 6.1 秒(待たされた)

この確認例では、MigrateAsync() がロックの解放まで待機し、解放後に処理を継続することを確認できています。ロック待機の確認であり、あらゆる運用条件で二重適用が起きないことを実測で証明したものではありません。

マイグレーションを自分でトランザクションに包んではいけない

Section titled “マイグレーションを自分でトランザクションに包んではいけない”

公式のマイグレーション適用ガイドは、明示的トランザクションでの MigrateAsync() のラップをサポートしていません。次は、避けるべき書き方です。

// これは公式にサポートされない書き方
await db.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(cancellationToken);
await db.Database.MigrateAsync(cancellationToken);
await tx.CommitAsync(cancellationToken);
});

公式ドキュメントは、MigrateAsync を明示的トランザクションで包むことを「サポートされない」と明記しています。 理由は、外側でトランザクションを開始してしまうと上で説明したデータベースロックを取得できなくなり、同時実行から保護されなくなるためです。EF Core 9 以降の Migrate / MigrateAsync は、必要なトランザクションと実行戦略を自分で管理します。

EF Core 9 ではこのパターンが MigrationsUserTransactionWarning による例外になりました。EF Core 10.0.11 の上のコードの確認例では、例外ではなく同じ警告 ID のログを確認できています。成功観測を、公式にサポートされる使い方と解釈しないでください。

warn: RelationalEventId.MigrationsUserTransactionWarning[20412] (Microsoft.EntityFrameworkCore.Migrations)

例外にならなくても、ロックによる保護が失われることに変わりはありません。外側のトランザクションと実行戦略は外して、MigrateAsync() をそのまま呼んでください。

await db.Database.MigrateAsync(cancellationToken);

SQLite では放置されたロックが残る

Section titled “SQLite では放置されたロックが残る”

SQLite にはアプリケーションロックの仕組みがないため、公式ドキュメントによれば EF Core は代わりに __EFMigrationsLock テーブルを作成し、そこに行を挿入することでロックを表現します。次は、マイグレーション適用後のテーブル一覧の確認例です。

テーブル: __EFMigrationsLock, __EFMigrationsHistory

公式ドキュメントは、マイグレーションの異常終了でロックが残り、後続の移行が解除を無期限に待つ可能性を説明しています。次はロック行を残した状態を用意して、後続のマイグレーションの待機を確認する例です。

放置されたロック行を挿入した
8 秒経ってもロック待ちのまま
ロックテーブルを削除したら完了した

この確認例では、8 秒後も待機し、残留ロックの除去後に完了することを確認できています。無期限に観測した結果ではありません。公式ドキュメントが解決方法に挙げる __EFMigrationsLock テーブルの削除は、次の SQL です。

DROP TABLE "__EFMigrationsLock";

特定のテーブルをマイグレーションの対象から外す

Section titled “特定のテーブルをマイグレーションの対象から外す”

同じエンティティ型を複数の DbContext にマップしたい場面があります。境界づけられたコンテキスト (Bounded Context) ごとに DbContext を分ける設計では、片方のコンテキストがテーブルを所有し、もう片方は読み取りのために同じテーブルを参照するといった構成になります。このとき両方のコンテキストでマイグレーションを作ると、同じテーブルを二重に作ろうとして衝突します。

ExcludeFromMigrations を使うと、モデルには含めたままマイグレーションの対象からだけ外せます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<AuditLog>()
.ToTable("AuditLogs", t => t.ExcludeFromMigrations());
}

上のモデルの GenerateCreateScript() の確認例では、Products の CREATE TABLE のみが生成され、AuditLogs の DDL は含まれていません。一方、エンティティ型はモデルに残るため、db.AuditLogs に対するクエリでは SELECT COUNT(*) FROM [AuditLogs] の発行を確認できています。

マイグレーション履歴テーブルをカスタマイズする

Section titled “マイグレーション履歴テーブルをカスタマイズする”

EF Core は適用済みのマイグレーションを __EFMigrationsHistory テーブルに記録します。このテーブルの名前・スキーマ・列名は変更できます。既存のデータベースの命名規則に合わせたい場合や、アプリケーション用のテーブルと分けて管理したい場合に使います。

テーブル名とスキーマは MigrationsHistoryTable() で指定します。

options.UseSqlServer(
connectionString,
x => x.MigrationsHistoryTable("__MyMigrationsHistory", "mySchema"));

列名など、それ以上の構成を変えるには、プロバイダー固有の IHistoryRepository サービスを差し替えます。次は SQL Server で MigrationId 列の名前を Id に変える例です。

#pragma warning disable EF1001
internal class MyHistoryRepository : SqlServerHistoryRepository
{
public MyHistoryRepository(HistoryRepositoryDependencies dependencies)
: base(dependencies)
{
}
protected override void ConfigureTable(EntityTypeBuilder<HistoryRow> history)
{
base.ConfigureTable(history);
history.Property(h => h.MigrationId).HasColumnName("Id");
}
}
options
.UseSqlServer(connectionString)
.ReplaceService<IHistoryRepository, MyHistoryRepository>();

次は、両方の構成を適用するマイグレーションの、履歴テーブル作成 SQL の確認例です。

-- SQL Server
IF OBJECT_ID(N'[mySchema].[__MyMigrationsHistory]') IS NULL
BEGIN
IF SCHEMA_ID(N'mySchema') IS NULL EXEC(N'CREATE SCHEMA [mySchema];');
CREATE TABLE [mySchema].[__MyMigrationsHistory] (
[Id] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___MyMigrationsHistory] PRIMARY KEY ([Id])
);
END;

SQL Server 2022 への dotnet ef database update の確認例では、mySchema.__MyMigrationsHistory と、その Id / ProductVersion の 2 列を確認できています。


マスターデータや動作確認用の初期データを投入する方法は 2 つあります。

1 つ目は HasData です。モデルの一部として宣言し、マイグレーションの中に InsertData として埋め込まれます。主キーを明示的に指定する必要があります。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().HasData(
new Blog { Id = 1, Name = "既定のブログ", Url = "https://example.com" });
}

HasData はマイグレーションの差分計算に組み込まれるため、値を書き換えると次のマイグレーションで UpdateData が生成されます。一方で、実行時にしか決まらない値(現在時刻や外部 API から取得する値)は扱えません。

2 つ目は EF Core 9 で追加された UseSeeding / UseAsyncSeeding です。こちらは通常の DbContext 操作としてデータを投入するため、任意のロジックを書けます。MigrateAsync() や EnsureCreatedAsync() の実行時に呼び出されます。

builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString)
.UseSeeding((context, _) =>
{
if (!context.Set<Blog>().Any(b => b.Name == "既定のブログ"))
{
context.Set<Blog>().Add(new Blog
{
Name = "既定のブログ",
Url = "https://example.com"
});
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
if (!await context.Set<Blog>()
.AnyAsync(b => b.Name == "既定のブログ", cancellationToken))
{
context.Set<Blog>().Add(new Blog
{
Name = "既定のブログ",
Url = "https://example.com"
});
await context.SaveChangesAsync(cancellationToken);
}
}));

既存データベースからスキャフォールディングする

Section titled “既存データベースからスキャフォールディングする”

既存のデータベースからエンティティと DbContext を生成する dotnet ef dbcontext scaffold には、そのまま使うと危険な既定の挙動があります。

このコマンドをそのまま実行すると、生成された DbContext の OnConfiguring に接続文字列が埋め込まれます。次は、SQL Server 2022 への接続文字列を直接渡した確認例で、生成コードとともに確認できている #warning です(接続先を伏せ、警告文の先頭部分のみ掲載)。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code.
=> optionsBuilder.UseSqlServer("Server=...;User Id=sa;Password=...");

公式ドキュメントは、これは「生成されたコードが最初に使うときにいきなり動かないという体験を避けるため」であり、接続文字列が製品コードに存在してはならないと明記しています。--no-onconfiguring は OnConfiguring の生成を抑止します。この確認例でも、生成ファイルに OnConfiguring と接続文字列が含まれないことを確認できています。

Terminal window
dotnet ef dbcontext scaffold "<接続文字列>" Microsoft.EntityFrameworkCore.SqlServer \
--output-dir Models --no-onconfiguring

既定値を持つ bool 列は bool? にならない

Section titled “既定値を持つ bool 列は bool? にならない”

データベースファーストで運用していると、EF Core 8.0 での変更が生成結果に効いてきます。既定値の制約を持つ NULL 非許容の bit 列は、以前は bool? として生成されていましたが、EF Core 8.0 以降は bool として生成されます。

次は、SQL Server 2022 の以下のテーブルをスキャフォールディングする確認例です。

-- SQL Server
CREATE TABLE Articles (
Id int IDENTITY PRIMARY KEY,
Title nvarchar(200) NOT NULL,
IsPublished bit NOT NULL CONSTRAINT DF_Articles_IsPublished DEFAULT 1
);

この確認例で生成されるプロパティと構成は、次のとおりです。

public bool IsPublished { get; set; } // bool? ではない
entity.Property(e => e.IsPublished).HasDefaultValue(true, "DF_Articles_IsPublished");

以前 bool? にしていたのは、bool の CLR 既定値が false のため、false を設定しても「未設定」と区別できず、データベースの既定値 true が入ってしまう問題があったからです。EF Core 8.0 では、値が設定済みかどうかを判定する基準値(センチネル)を変更できるようになり、既定値が true の bool プロパティにはこれが自動で適用されます。このモデルの確認例でも Sentinel = True です。

このモデルの確認例では、false と true で発行される INSERT は次のとおりです。

-- SQL Server:IsPublished = false を設定した場合(値が送られる)
INSERT INTO [Articles] ([IsPublished], [Title])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1);
-- SQL Server:IsPublished = true を設定した場合(列が外れ、DB の既定値が使われて読み戻される)
INSERT INTO [Articles] ([Title])
OUTPUT INSERTED.[Id], INSERTED.[IsPublished]
VALUES (@p2);

dotnet ef コマンドは、設計時に DbContext のインスタンスを生成する必要があります。通常はアプリケーションの Program.cs からホストを構築して解決しますが、それが難しい構成(クラスライブラリにマイグレーションを置く場合など)では IDesignTimeDbContextFactory<T> を実装します。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace BloggingApi.Data;
public class BloggingContextFactory : IDesignTimeDbContextFactory<BloggingContext>
{
public BloggingContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<BloggingContext>();
optionsBuilder.UseSqlServer(
Environment.GetEnvironmentVariable("BLOGGING_CONNECTION")
?? @"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True");
return new BloggingContext(optionsBuilder.Options);
}
}

マイグレーションを別プロジェクトに置く場合は、コマンドで対象を指定します。ここで指定する 2 つのオプションは役割が異なります。公式ドキュメントによれば、--project(対象プロジェクト)は生成されたファイルを受け取るプロジェクト、--startup-project(スタートアッププロジェクト)はツールがビルドして実行するプロジェクトです。ツールは接続文字列やモデルの構成を得るために、設計時にアプリケーションのコードを実行する必要があるためです。

Terminal window
dotnet ef migrations add InitialCreate \
--project BloggingApi.Data \
--startup-project BloggingApi.Data

複数のフレームワークを対象にしているプロジェクト

Section titled “複数のフレームワークを対象にしているプロジェクト”

TargetFrameworks で複数のフレームワークを対象にしているプロジェクトでは、EF Core 10 からどのフレームワークを使うかの指定が必須になりました。

The project targets multiple frameworks.
Use the --framework option to specify which target framework to use.
Terminal window
dotnet ef migrations add Init --framework net10.0

公式の EF Core 10 の変更説明に従い、複数のフレームワークを対象にする場合は実行対象を明示します。ライブラリプロジェクトで複数フレームワークを対象にしている場合は、CI のスクリプトにも --framework を追加してください。

生成されるナビゲーション名は EF Core 8 で変わった

Section titled “生成されるナビゲーション名は EF Core 8 で変わった”

既存のデータベースから生成されるナビゲーションの名前は、EF Core 8 で変わりました。以前は複合外部キーの列名に共通の接頭辞があると、そこから名前を作ることがあり、S や Student_、ひどいときは _ だけという名前が生成されていました。

現在はこの規則が廃止されています。Students の複合主キーを Sup_Id1 / Sup_Id2 で参照する Enrollments の確認例でも、生成されるナビゲーション名は接頭辞の Sup ではなく、参照先の型名に基づく Student です。

Enrollment.cs
public virtual Student Student { get; set; } = null!;
// Student.cs
public virtual ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();

同じ階層の複数のコレクションナビゲーションを Include すると、EF Core は既定で 1 つの SQL に JOIN してまとめます。このとき、兄弟コレクションの行の組み合わせが増える カルテシアン爆発 (Cartesian Explosion) が起こります。転送量そのものを減らす手段は付録5の「インデックスを正しく張る」ではなく投影であり、判断の順序は付録5の「まず計測する」に従ってください。

var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Contributors)
.ToListAsync(cancellationToken);

Post が 10 件、Contributor が 10 件あるブログでは、JOIN の結果は 100 行になり、ブログの列がすべての行に重複して転送されます。

これを避けるには AsSplitQuery() を使い、コレクションごとに別々のクエリを発行させます。

var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Contributors)
.AsSplitQuery()
.ToListAsync(cancellationToken);

アプリケーション全体の既定を分割クエリにすることもできます。

builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString,
sqlOptions => sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));

個別のクエリで単一クエリに戻したい場合は AsSingleQuery() を呼びます。

観点単一クエリ(既定)分割クエリ
ラウンドトリップ数1 回コレクションの数だけ増える
データ転送量重複により増える可能性がある兄弟コレクション間の行の積を避けられる。すべての列の重複がなくなるわけではない
整合性データベースの単一クエリと分離レベルの保証範囲に従うクエリ間で他のトランザクションによる変更が入り得る

.NET 10 では LINQ に LeftJoin と RightJoin の演算子が追加され、EF Core 10 はこれを SQL の LEFT JOIN / RIGHT JOIN に変換します。

var results = await context.Students
.LeftJoin(
context.Departments,
student => student.DepartmentId,
department => department.Id,
(student, department) => new
{
student.Name,
DepartmentName = department != null ? department.Name : "所属なし"
})
.ToListAsync(cancellationToken);

従来は SelectMany と GroupJoin、DefaultIfEmpty を組み合わせる必要がありましたが、意図が明確に表現できるようになりました。

大文字小文字の区別は照合順序が決める

Section titled “大文字小文字の区別は照合順序が決める”

C# の == は大文字小文字を区別しますが、SQL に翻訳された後は、データベースの照合順序 (collation) が区別するかどうかを決めます。 次のクエリの確認例では、大文字小文字を区別しない照合順序で John と JOHN の両方への一致を確認できています。

var count = await context.Customers.Where(c => c.Name == "john").CountAsync(cancellationToken);
// SQL: WHERE [c].[Name] = N'john' → 実測で 2 件(John と JOHN)

EF Core は == を単に SQL の = に翻訳するだけで、大文字小文字の扱いを揃えようとはしません。これは意図的な設計です。そのため、StringComparison を受け取るオーバーロードは翻訳できず例外になります。

// C# としてはコンパイルできる。Where だけではまだ SQL に翻訳されない
var query = context.Customers
.Where(c => c.Name.Equals("john", StringComparison.OrdinalIgnoreCase));
// ここで SQL 翻訳が行われ、InvalidOperationException になる
await query.ToListAsync(cancellationToken);

クエリ単位で照合順序を指定したい場合は EF.Functions.Collate を使います。

var exact = await context.Customers
.Where(c => EF.Functions.Collate(c.Name, "SQL_Latin1_General_CP1_CS_AS") == "John")
.CountAsync(cancellationToken);
// SQL: WHERE [c].[Name] COLLATE SQL_Latin1_General_CP1_CS_AS = N'John' → 実測で 1 件

照合順序の上書きとインデックスへの影響

Section titled “照合順序の上書きとインデックスへの影響”

公式の照合順序とインデックスの説明では、インデックスは列の照合順序を引き継ぎ、クエリで別の照合順序を指定すると、その利用を妨げる可能性があります。次は、Name に非クラスター化インデックスを持つ 2,001 行のテーブルで、SQL Server 2022 の SET SHOWPLAN_ALL ON により取得した推定プランの確認例です。実行時間や実際の実行プランの測定ではありません。

クエリ実行プラン
WHERE [Name] = N'John'Index Seek(OBJECT:(...[IX_Customers_Name]))
WHERE [Name] COLLATE SQL_Latin1_General_CP1_CS_AS = N'John'Clustered Index Scan(OBJECT:(...[PK_Customers]))
WHERE LOWER([Name]) = N'john'Clustered Index Scan(OBJECT:(...[PK_Customers]))

公式の照合順序の説明は、EF.Functions.Collate や string.ToLower がインデックスの利用を妨げる可能性を警告しています。上の条件ではシークとスキャンの推定プランの違いを確認できていますが、処理時間の差や、行数を増やしたときの性能を示す測定ではありません。

// 列の照合順序として定義する(この列に対するすべてのクエリに適用される)
modelBuilder.Entity<Customer>()
.Property(c => c.Name)
.UseCollation("SQL_Latin1_General_CP1_CS_AS");

bool?・int? の null を文字列に変換する

Section titled “bool?・int? の null を文字列に変換する”

EF Core 9 の変更内容は、null 許容値型の ToString() の翻訳を C# の動作に合わせ、null の場合は空文字列を返すよう変更したと説明しています。以下は、SQL Server プロバイダー / EF Core 10.0.11 の bool? と int? 列の確認例です。

var q = db.Things.Select(x => new { F = x.Flag.ToString(), N = x.Num.ToString() });

この確認例では、bool? と int? が null の行の結果は空文字列です。生成 SQL は次のとおりです。

SELECT CASE [t].[Flag]
WHEN CAST(0 AS bit) THEN N'False'
WHEN CAST(1 AS bit) THEN N'True'
ELSE N'' END AS [F],
COALESCE(CONVERT(varchar(11), [t].[Num]), '') AS [N]
FROM [Things] AS [t]
Flag='' (null? False) Num='' (null? False)
Flag='True' Num='5'

返ってくるのは null ではなく空文字列である点に注意してください。== null での判定は成立しません。以前の動作に戻したい場合は、公式ドキュメントが示すとおりクエリを書き換えます。

db.Things.Select(x => x.Flag == null ? null : x.Flag.ToString());

文字列の主キーは大文字小文字を区別せずに比較される

Section titled “文字列の主キーは大文字小文字を区別せずに比較される”

照合順序の話にはもう 1 つ落とし穴があります。EF Core 8 以降、SQL Server / Azure SQL プロバイダーでは、文字列の主キーや外部キーの値を .NET 側でも大文字小文字を区別せずに比較します。データベース側の既定の照合順序に合わせるための変更です。

つまり、abc を追跡している状態で ABC という別のインスタンスを追跡させようとすると、同じキーとみなされて失敗します。

db.Attach(new Customer { Id = "abc", Name = "小文字" });
db.Attach(new Customer { Id = "ABC", Name = "大文字" }); // ここで例外
InvalidOperationException: The instance of entity type 'Customer' cannot be tracked
because another instance with the same key value for {'Id'} is already being tracked.

この例でも、データベースへの問い合わせ前の 変更追跡の段階での例外を確認できています。

大文字小文字を区別したいのであれば、公式の移行手順が示すとおり、キーのプロパティに、変更追跡で使う ValueComparer(値比較子) を SetValueComparer で明示的に構成します。プロバイダー値の比較に使う SetProviderValueComparer とは区別してください。

modelBuilder.Entity<Customer>()
.Property(c => c.Id)
.Metadata.SetValueComparer(
new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.Ordinal),
v => v.GetHashCode()));

文字列の主キーは EF Core 側でも大文字小文字を区別しない

Section titled “文字列の主キーは EF Core 側でも大文字小文字を区別しない”

ここまでの照合順序の説明はデータベース側の比較です。一方、EF Core が変更追跡でキー値を突き合わせるときの比較にも注意が必要です。EF Core 8.0 以降、SQL Server / Azure SQL プロバイダーでは、文字列のキー値が .NET の大文字小文字を区別しない序数比較子で比較されます。それ以前は大文字小文字を区別する比較子でした。

公式ドキュメントはこの変更の理由を、「SQL Server は既定で、外部キーの値が主キーの値に一致するかを大文字小文字を区別せずに比較する。EF が大文字小文字を区別して比較すると、本来つながるはずの外部キーと主キーが結び付かないことがある」と説明しています。

この文字列キーの確認例では、比較子は CaseInsensitiveValueComparer で、追跡中の "ABC" を小文字の "abc" で検索して取得できることを確認できています。

キーの ValueComparer = CaseInsensitiveValueComparer
comparer.Equals("ABC", "abc") = True

区別が必要な場合は、ValueComparer を明示的に設定します。

var comparer = new ValueComparer<string>(
(l, r) => string.Equals(l, r, StringComparison.Ordinal),
v => v.GetHashCode(),
v => v);
modelBuilder.Entity<Blog>().Property(e => e.Id).Metadata.SetValueComparer(comparer);

複数の列で並べ替えるキーセットページング

Section titled “複数の列で並べ替えるキーセットページング”

並べ替えのキーが 1 つでは一意にならない場合、キーセットページングの条件は単純な > の連鎖では書けません。次のように書くと、境界にある行が丸ごと欠落します。

// 誤り: 同じ Date の行が残っていても次の日付へ飛んでしまう
.Where(p => p.Date > lastDate)

次は、2026-01-01 と 2026-01-02 を各 3 件持つデータを、Date と Id の昇順で 2 件ずつページングする確認例です。1 ページ目は Id=1, 2、境界は Date=2026-01-01, Id=2 です。

条件生成される WHERE 句2 ページ目の結果
p.Date > lastDate[p].[Date] > @lastDateId=4, 5 — Id=3 が欠落
公式の OR パターン[p].[Date] > @lastDate OR ([p].[Date] = @lastDate AND [p].[Id] > @lastId)Id=3, 4 — 正しい

公式ドキュメントが示す正しい書き方は、最後のキー以外が等しい場合を OR でつなぐ形です。

var page = await context.Posts
.AsNoTracking()
.OrderBy(p => p.Date)
.ThenBy(p => p.Id)
.Where(p => p.Date > lastDate || (p.Date == lastDate && p.Id > lastId))
.Take(pageSize)
.ToListAsync(cancellationToken);

並べ替えキーが増えるほど、この OR の項も増えていきます。

関連データを読み込まずに数える

Section titled “関連データを読み込まずに数える”

本編で扱った明示的読み込み (Collection(...).LoadAsync()) は、関連データをすべてメモリに読み込みます。件数を数えたいだけの場合や、条件に合うものだけが欲しい場合は Query() を使います。Query() はそのナビゲーションに対応する IQueryable を返すので、後ろに LINQ を続けられます。

この節の例は、本編とは別の 投稿に評価値を持つ検証用モデルを使います。同名の型を本編のモデルへ混ぜず、次の RatingQuerySample 名前空間に配置してください。以下の context は、SQL Server の接続文字列を UseSqlServer に設定した RatingContext です。

using Microsoft.EntityFrameworkCore;
namespace RatingQuerySample;
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Post> Posts { get; set; } = [];
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int Rating { get; set; }
public int BlogId { get; set; }
public Blog? Blog { get; set; }
}
public class RatingContext(DbContextOptions<RatingContext> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
}
var blog = await context.Blogs.SingleAsync(b => b.Id == id, cancellationToken);
// 件数だけを数える。Post のインスタンスは 1 件も作られない
var postCount = await context.Entry(blog)
.Collection(b => b.Posts)
.Query()
.CountAsync(cancellationToken);
// 条件に合うものだけを読み込む
var goodPosts = await context.Entry(blog)
.Collection(b => b.Posts)
.Query()
.Where(p => p.Rating > 3)
.ToListAsync(cancellationToken);

SQL Server 2022 の確認例では、前者は次の SQL で、blog.Posts は空のままです。

-- SQL Server
SELECT COUNT(*)
FROM [Posts] AS [p]
WHERE [p].[BlogId] = @p

後者は WHERE に条件が積まれ、返ってきた 2 件だけが追跡されます。

-- SQL Server
SELECT [p].[Id], [p].[BlogId], [p].[Rating], [p].[Title]
FROM [Posts] AS [p]
WHERE [p].[BlogId] = @p AND [p].[Rating] > 3

参照ナビゲーション(単一の相手)を明示的に読み込むときは Collection ではなく Reference を使います。

await context.Entry(post)
.Reference(p => p.Blog)
.LoadAsync(cancellationToken);

存在チェックは Count ではなく Any を使う

Section titled “存在チェックは Count ではなく Any を使う”

「関連するレコードが 1 件でもあるか」は Any() で表せます。EF Core 9 の公式の最適化例は、b.Posts.Count > 0 を EXISTS に翻訳する例を示しています。次は SQL Server 2022 / EF Core 10 の同じモデルで比較した生成 SQL です。Count を含むすべての式への最適化を保証する表ではありません。

LINQ の書き方生成される SQL の WHERE 句
b.Posts.Any()EXISTS (SELECT 1 FROM [Posts] ...)
b.Posts.Count > 0(プロパティ)EXISTS (SELECT 1 FROM [Posts] ...)
b.Posts.Count != 0(プロパティ)EXISTS (SELECT 1 FROM [Posts] ...)
b.Posts.Count() > 0(メソッド)(SELECT COUNT(*) FROM [Posts] ...) > 0
b.Posts.Count() != 0(メソッド)(SELECT COUNT(*) ...) <> 0 OR (SELECT COUNT(*) ...) IS NULL

この比較で確認したのは生成 SQL の違いであり、実行時間の差を測ったものではありません。生成 SQL だけを根拠に、関連レコードが増えるほど性能差も大きくなるとは判断できません。

同じモデルの確認例では、Count == 0 は COUNT(*) = 0、!Any() は NOT EXISTS です。存在しないことは、次のように !Any() で表せます。

// 良い例: NOT EXISTS になる
var emptyBlogs = await db.Blogs.Where(b => !b.Posts.Any()).ToListAsync();

エンティティをクエリ結果として読み込むときに、特定のナビゲーションも自動的に読み込むようモデル側で構成できます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().Navigation(b => b.Theme).AutoInclude();
}

次は、SQL Server 2022 でこの構成の db.Blogs を問い合わせる確認例です。Include を書かなくても JOIN を含む SQL を確認できています。

-- SQL Server
SELECT [b].[Id], [b].[Name], [b].[ThemeId], [t].[Id], [t].[Color]
FROM [Blogs] AS [b]
LEFT JOIN [Theme] AS [t] ON [b].[ThemeId] = [t].[Id]

公式ドキュメントは、この構成が「結果に含まれるすべてのエンティティに対して適用される」と述べています。つまり、別のエンティティの Include の結果としてぶら下がってきた Blog にも Theme の読み込みが付いてきます。

特定のクエリでだけ読み込みたくない場合は IgnoreAutoIncludes() を使います。

var blogs = await db.Blogs.IgnoreAutoIncludes().ToListAsync();
-- SQL Server
SELECT [b].[Id], [b].[Name], [b].[ThemeId]
FROM [Blogs] AS [b]

null の比較は C# と SQL で意味が違う

Section titled “null の比較は C# と SQL で意味が違う”

SQL のデータベースは比較を 3 値論理 (true / false / null) で扱いますが、C# は 2 値のブール論理です。EF Core は LINQ を SQL に変換するとき、この差を埋めるために追加の null チェックを補います。

次は、SQL Server 2022 での確認例に使うエンティティです。String1 と String2 はどちらも null を許容します。

var q = await context.Entities
.Where(e => e.String1 != e.String2)
.ToListAsync(cancellationToken);

生成される SQL には、C# と同じ意味になるよう補正が入ります。

-- SQL Server
SELECT [e].[Id], [e].[String1], [e].[String2]
FROM [Entities] AS [e]
WHERE ([e].[String1] <> [e].[String2] OR [e].[String1] IS NULL OR [e].[String2] IS NULL)
AND ([e].[String1] IS NOT NULL OR [e].[String2] IS NOT NULL)

一方 == の場合はもっと単純です。!= は == より複雑で遅くなりがちなので、書き換えられるなら等価比較を使ってください。

-- SQL Server
SELECT [e].[Id], [e].[String1], [e].[String2]
FROM [Entities] AS [e]
WHERE [e].[String1] = [e].[String2] OR ([e].[String1] IS NULL AND [e].[String2] IS NULL)

null を明示的に除外しておくと、EF Core はその列を null 非許容として扱えるため SQL が単純になります。

var q = await context.Entities
.Where(e => e.String1 != null && e.String2 != null && e.String1 != e.String2)
.ToListAsync(cancellationToken);
-- SQL Server
SELECT [e].[Id], [e].[String1], [e].[String2]
FROM [Entities] AS [e]
WHERE [e].[String1] IS NOT NULL AND [e].[String2] IS NOT NULL AND [e].[String1] <> [e].[String2]

UseRelationalNulls(true) を指定すると、この補正を無効にして SQL 本来の null の扱いをそのまま使えます。

options.UseSqlServer(connectionString, o => o.UseRelationalNulls(true));

SQLite の bool? の null も空文字になる

Section titled “SQLite の bool? の null も空文字になる”

SQLite プロバイダーでも、bool? の列に対する ToString() は SQL に翻訳され、値が null なら空文字列を返します。これは string? などを含むすべての型への保証ではありません。SQL Server の例と文字列型との対照に続き、ここでは SQLite での bool? の射影を確認します。

var rows = await db.Items
.Select(x => new { x.Id, Text = x.Flag.ToString() }) // Flag は bool?
.ToListAsync();

SQLite のこの確認例では、次の SQL に翻訳され、Flag が NULL の行の結果は長さ 0 の文字列です。

SELECT "i"."Id", CASE "i"."Flag"
WHEN 0 THEN 'False'
WHEN 1 THEN 'True'
ELSE ''
END AS "Text"
FROM "Items" AS "i"

これは Nullable<T>.ToString() が C# 側でも空文字を返す挙動に合わせたものです。以前の挙動に戻したい場合は、クエリを次のように書き換えます。

var oldBehavior = db.Items.Select(x => x.Flag == null ? null : x.Flag.ToString());

エンティティをそのまま JSON にすると循環参照で失敗する

Section titled “エンティティをそのまま JSON にすると循環参照で失敗する”

EF Core はナビゲーションプロパティを自動的に補完 (fix-up) するため、オブジェクトグラフに循環ができます。Blog を Include で読み込むと Blog.Posts に Post が入り、その Post.Blog が元の Blog を指すためです。公式ドキュメントは、この循環をシリアル化フレームワークが扱えない場合があると明記しています。

次は、SQLite から Include で取得した Blog を、.NET 10 の JsonSerializer.Serialize に既定の設定で渡す確認例の例外です(メッセージとパスは一部省略)。

System.Text.Json.JsonException: A possible object cycle was detected. This can either be
due to a cycle or if the object depth is larger than the maximum allowed depth of 64.
Path: $.Posts.Blog.Posts.Blog.Posts.Blog....

対処は 3 つあります。1 つ目は、ReferenceHandler.IgnoreCycles を指定して循環部分を null に置き換える方法です。最小 API では ConfigureHttpJsonOptions、MVC やコントローラーでは AddControllers().AddJsonOptions(...) で設定します。

builder.Services.ConfigureHttpJsonOptions(
options => options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);

同じデータで実測した結果は次のとおりで、循環している blog が null になります。

{"id":1,"name":"A","posts":[{"id":1,"title":"P1","blogId":1,"blog":null}]}

2 つ目は ReferenceHandler.Preserve です。こちらは循環を $id と $ref の参照に置き換えます。次の JSON は、上の例と同じプロパティ・値を持つグラフを JsonSerializer.Serialize に渡し、new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve } を指定して再現したものです。最小 API の応答とは異なり、プロパティ名は Id・Posts などの元の表記を保ちます。

{"$id":"1","Id":1,"Name":"A","Posts":{"$id":"2","$values":[
{"$id":"3","Id":1,"Title":"P1","BlogId":1,"Blog":{"$ref":"1"}}]}}

最小 API で ConfigureHttpJsonOptions の指定を ReferenceHandler.Preserve に変える場合も参照情報を保持しますが、Web 用の既定設定は camelCaseです。ASP.NET Core 10 / SQLite の同じデータの確認例でも、キーは id・posts です。Preserve 自体がキー名の大小文字を切り替えるわけではありません。

3 つ目は、循環の原因になっているナビゲーションプロパティに System.Text.Json.Serialization 名前空間の [JsonIgnore] を付けて、シリアル化の対象から外す方法です。

LINQ で表現できないクエリや、ストアドプロシージャの呼び出しには生の SQL を使います。生の SQL と SaveChanges を 1 つのトランザクションにまとめる場合は付録4の「セーブポイント」と「接続の回復性とトランザクションの併用」もあわせて確認してください。

var blogs = await context.Blogs
.FromSql($"SELECT * FROM [Blogs] WHERE [Url] LIKE {pattern}")
.AsNoTracking()
.ToListAsync(cancellationToken);

FromSql は 補間文字列 (FormattableString) の埋込値を SQL パラメーターに変換し、SQL として連結しません。次は、上の LIKE クエリを SQLite で実行し、pattern に https://ok.example.com' OR '1'='1 を渡す確認例です。Url が https://ok.example.com の行に対してこの値は一致せず、結果は 0 件です。

.param set p0 'https://ok.example.com'' OR ''1''=''1'
SELECT * FROM [Blogs] WHERE [Url] LIKE @p0

エンティティ型を返さないスカラークエリには SqlQuery を使います。

var ids = await context.Database
.SqlQuery<int>($"SELECT [Id] FROM [Blogs] WHERE [CreatedAt] > {threshold}")
.ToListAsync(cancellationToken);

未マップ型 (DTO) を直接受け取る

Section titled “未マップ型 (DTO) を直接受け取る”

SqlQuery が扱えるのはスカラー値だけではありません。EF Core のモデルに含まれていない、マッピング可能な CLR 型にも結果を詰められます(EF Core 8.0 で追加)。複数のテーブルを結合した結果や、列の部分集合をそのまま DTO に受け取れるため、生の SQL を書くときに DbCommand などの低レベルな API へ降りる必要がなくなります。

この節の Blog / Post と context も、「関連データを読み込まずに数える」の検証用モデル(RatingQuerySample.RatingContext)を使います。Rating はこのモデルの Post に定義されています。本編の BloggingContext にそのまま貼り付ける例ではありません。

public class PostSummary
{
public string BlogName { get; set; } = "";
public string PostTitle { get; set; } = "";
public int Rating { get; set; }
}
var summaries = await context.Database
.SqlQuery<PostSummary>(
$"""
SELECT b.[Name] AS BlogName, p.[Title] AS PostTitle, p.[Rating]
FROM [Posts] AS p INNER JOIN [Blogs] AS b ON p.[BlogId] = b.[Id]
""")
.ToListAsync(cancellationToken);

型はデータベースのどのテーブルとも一致している必要がありません。パラメーター付きコンストラクターや [Column] 属性など、EF Core のマッピング機構も使えます。結果は変更追跡の対象ではなく、この確認例でも ChangeTracker.Entries() は 0 件です。

ここまでの例は、生の SQL を書かずに LINQ の Select だけでも同じ結果が得られます。SQL を書く必要が本当にあるのかは、先に検討してください。

// 上の Where を合成した SQL と同じ結果を LINQ だけで得る
var summaries = await context.Posts
.Select(p => new PostSummary
{
BlogName = p.Blog!.Name,
PostTitle = p.Title,
Rating = p.Rating,
})
.Where(x => x.Rating >= 5)
.ToListAsync(cancellationToken);

更新系の SQL は ExecuteSqlAsync です。

await context.Database.ExecuteSqlAsync(
$"UPDATE [Blogs] SET [Name] = {newName} WHERE [Id] = {id}",
cancellationToken);
// 危険:絶対に書かない(EF1003 警告が出る)
var unsafeBlogs = await context.Blogs
.FromSqlRaw("SELECT * FROM [Blogs] WHERE [Url] = '" + userInput + "'")
.ToListAsync(cancellationToken);
// 危険:見た目は FromSql とほぼ同じだが値が埋め込まれる(EF1002 警告が出る)
var alsoUnsafeBlogs = await context.Blogs
.FromSqlRaw($"SELECT * FROM [Blogs] WHERE [Url] = '{userInput}'")
.ToListAsync(cancellationToken);
// 安全:パラメーター化する
var safeBlogs = await context.Blogs
.FromSqlRaw("SELECT * FROM [Blogs] WHERE [Url] = {0}", userInput)
.ToListAsync(cancellationToken);

パラメーターにできるのは値だけです。公式ドキュメントは「データベースは列名(やスキーマのその他の部分)のパラメーター化を許可しない」と明記しています。次のように列名を補間しても意図どおりには動きません。

var columnName = "Owner";
var columnValue = "johndoe";
// 動かない
var blogs = await context.Blogs
.FromSql($"SELECT * FROM [Blogs] WHERE {columnName} = {columnValue}")
.ToListAsync(cancellationToken);

どうしても列名を動的に組み立てる必要がある場合は、公式ドキュメントが示すとおり FromSqlRaw を使い、列名は文字列補間で埋め込み、値は DbParameter として渡します。

var columnName = "Owner";
var columnValue = new SqlParameter("columnValue", "johndoe");
var blogs = await context.Blogs
.FromSqlRaw($"SELECT * FROM [Blogs] WHERE {columnName} = @columnValue", columnValue)
.ToListAsync(cancellationToken);

同じデータを使うこの形の確認例では、期待する 1 件の取得を確認できています。ただし FromSqlRaw への補間文字列により EF1002 の警告が出ます。列名が安全な出所であることを確認したうえで、その箇所だけ警告を抑制してください。

FromSql の結果には LINQ を続けて適用できるため、共通部分だけ SQL で書き、絞り込みや並べ替えは LINQ に任せるといった使い分けが可能です。

var blogs = await context.Blogs
.FromSql($"SELECT * FROM [Blogs] WHERE [Rating] > {minRating}")
.Where(b => b.Url.Contains("dotnet"))
.OrderBy(b => b.Name)
.ToListAsync(cancellationToken);

ただし LINQ を続けられるのは、渡した SQL が合成可能 (composable) な場合だけです。EF Core は与えられた SQL をサブクエリとして扱うため、サブクエリに置けない構文が含まれていると失敗します。公式ドキュメントは合成できない例として次を挙げています。

書き方SQL Server 2022 での実測結果
SELECT * FROM [Blogs] WHERE ...成功
末尾にセミコロンを付けるSqlException: Incorrect syntax near ';'.
TOP や OFFSET を伴わない ORDER BY を含めるSqlException: The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions, unless TOP, OFFSET or FOR XML is also specified.

このほか、SQL Server ではクエリレベルのヒント(OPTION (HASH JOIN) など)を末尾に付けた SQL も合成できません。合成する予定の SQL は SELECT で始まり、末尾のセミコロンを付けないのが基本だと覚えておいてください。

ストアドプロシージャを実行する

Section titled “ストアドプロシージャを実行する”

FromSql はストアドプロシージャの実行にも使えます。

var blogs = await context.Blogs
.FromSql($"EXECUTE dbo.GetPopularBlogs {minRating}")
.ToListAsync(cancellationToken);

補間した値は通常の FromSql と同じく DbParameter となり、SQL として連結されません。省略可能なパラメーターがあるストアドプロシージャでは、次のように SqlParameter で 名前付きパラメーターを指定できます。

var p = new SqlParameter("p", 5);
var blogs = await context.Blogs
.FromSql($"EXECUTE dbo.GetPopularBlogs @minRating={p}")
.ToListAsync(cancellationToken);

結果セットを返さないストアドプロシージャは ExecuteSql で呼び出します。この 3 行を更新する確認例では、戻り値の影響行数は 3 です。

var affected = await context.Database
.ExecuteSqlAsync($"EXECUTE dbo.BumpRatings {delta}", cancellationToken);

ユーザー定義関数とビューをマッピングする

Section titled “ユーザー定義関数とビューをマッピングする”

EF Core の公式パフォーマンスガイダンスは、EF が生成しない最適な SQL を使いたい場合の手段を 3 つ挙げています。1 つ目が前節の FromSql で、残りの 2 つがユーザー定義関数 (User-Defined Function: UDF) とデータベースビューです。FromSql は「その 1 か所でしか使わない SQL」に向く一方、複数のクエリから再利用したいロジックは関数やビューにするほうが管理しやすくなります。ビューや集計結果を主キーのない型として読む方法は付録2の「キーなしエンティティ型でビューや集計結果を読む」で扱います。

戻り値が単一の値であるスカラー関数は、シグネチャを合わせた CLR メソッドを定義し、HasDbFunction でマッピングします。引数を含めて SQL に翻訳できる場合は CLR メソッドの本体を実行せず、データベース関数を呼び出します。次の本体は、CLR 側で誤って実行された場合に例外を投げる実装です。

public class BloggingContext : DbContext
{
public int PostCountForBlog(int blogId) => throw new NotSupportedException();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDbFunction(
typeof(BloggingContext).GetMethod(nameof(PostCountForBlog), [typeof(int)])!);
}
}
var names = await context.Blogs
.Where(b => context.PostCountForBlog(b.Id) > 1)
.Select(b => b.Name)
.ToListAsync(cancellationToken);

次は SQL Server 2022 での生成 SQL の確認例です。WHERE 句に関数呼出しを確認できています。

SELECT [b].[Name]
FROM [Blogs] AS [b]
WHERE [dbo].[PostCountForBlog]([b].[Id]) > 1

行の集合を返すテーブル値関数 (Table-Valued Function: TVF) は、IQueryable<T> を返す CLR メソッドとしてマッピングします。本体には FromExpression を書きます。

public IQueryable<Post> PopularPosts(int likeThreshold)
=> FromExpression(() => PopularPosts(likeThreshold));
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDbFunction(
typeof(BloggingContext).GetMethod(nameof(PopularPosts), [typeof(int)])!);
}

DbSet と同じように LINQ を合成できるのが利点です。SQL Server の次の確認例でも、TVF に Where を続けたクエリは 1 本の SQL です。

var titles = await context.PopularPosts(5)
.Where(p => p.Title.StartsWith("a"))
.Select(p => p.Title)
.ToListAsync(cancellationToken);
SELECT [p].[Title]
FROM [dbo].[PopularPosts](@likeThreshold) AS [p]
WHERE [p].[Title] LIKE N'a%'

ビューとキーレスエンティティ型

Section titled “ビューとキーレスエンティティ型”

パラメーターが不要なら、ビューをマッピングする方法もあります。公式ドキュメントは「関数と違い、ビューはパラメーターを受け取れない」とその違いを説明しています。ビューには主キーがないことが多いため、キーレスエンティティ型 (keyless entity type) として定義します。

[Keyless]
public class BlogPostCount
{
public string BlogName { get; set; } = "";
public int PostCount { get; set; }
}
modelBuilder.Entity<BlogPostCount>()
.HasNoKey()
.ToView("View_BlogPostCounts");

BlogPostCount はモデルに登録済みなので、専用の DbSet プロパティを追加せず、Set<BlogPostCount>() から問い合わせられます。

var counts = await context.Set<BlogPostCount>()
.OrderBy(v => v.BlogName)
.ToListAsync(cancellationToken);

キーレスエンティティ型は公式ドキュメントで「DbContext で変更追跡されることが決してなく、したがって挿入・更新・削除もされない」と定義されています。上の確認例でも、直後の ChangeTracker.Entries() は 0 件です。AsNoTracking() の指定は不要です。


SQL Server 固有の関数を LINQ から呼ぶ

Section titled “SQL Server 固有の関数を LINQ から呼ぶ”

EF.Functions には、プロバイダーごとの固有関数を LINQ から呼ぶための拡張メソッドが用意されています。SQL Server では日付差分や型判定などが翻訳されます。

var q = db.Events.Where(e => EF.Functions.DateDiffDay(e.Start, e.End) > 10);
SELECT [e].[Id], [e].[End], [e].[Start], [e].[Title]
FROM [Evs] AS [e]
WHERE DATEDIFF(day, [e].[Start], [e].[End]) > 10

DateDiffDay のほかに DateDiffMonth や DateDiffYear などがあり、DATEDIFF の第 1 引数に対応します。2026-01-01 から 2026-03-15 までの DateDiffMonth の確認例は 2 です。経過日数ではなく、指定単位の境界をまたいだ回数に対応します。

IsDate は SQL Server の ISDATE に翻訳されます。

WHERE CAST(ISDATE([e].[Title]) AS bit) = CAST(1 AS bit)

全文検索カタログと全文検索インデックスは、EF Core 10 ではモデルから構成できません。公式ドキュメントは、空のマイグレーションを追加して SQL を直接書く方法を案内しています。

protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
sql: "CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;",
suppressTransaction: true);
migrationBuilder.Sql(
sql: "CREATE FULLTEXT INDEX ON Articles(Contents) KEY INDEX PK_Articles;",
suppressTransaction: true);
}

KEY INDEX には、その表の一意・非 NULL・単一列のインデックス名(通常は主キーのインデックス)を指定します。マイグレーションの一部の操作はトランザクション内で実行できないため、suppressTransaction: true でトランザクションから外します(付録 EF Core 4 の「保存をストアドプロシージャに割り当てる」でもこの指定の用途を説明しています)。

次は、全文検索を有効にした SQL Server 2022 でカタログとインデックスを作成した後の確認例です。EF.Functions.Contains の以下の SQL への翻訳と、該当行の取得を確認できています。

WHERE CONTAINS([a].[Contents], N'vegetables')