本文へスキップ

EF Core / LINQ ↔ PostgreSQL 対照表 ​

SQL は読めても LINQ のメソッド名をなかなか覚えられない場合は、このページから逆引きできます。サンプルでは第15~17章の Todo とカテゴリーを使います。ここはリファレンスなので、メインの学習ルートを続ける前にすべて読む必要はありません。

Where、Select、OrderBy は LINQ(Language Integrated Query、統合言語クエリ)のメソッドです。EF Core のデータベースプロバイダーがクエリを SQL に変換します。SQLite と PostgreSQL では変換結果が異なる場合があります。

技術詳細

メインの学習ルートでは引き続き SQLite を使用します。このページの既定の実行例も SQLite を使います。PostgreSQL の同等スクリプトと、Npgsql が実際に生成するクエリも掲載しています。同等スクリプトは読みやすさのための例であり、EF Core がまったく同じ SQL を生成するという意味ではありません。

サンプル全体と実行方法 ​

このサンプルはコンソールアプリで、データベース操作だけを確認します。HTTP サービスは起動しません。実行するたびに新しいインメモリデータベースが作成され、終了すると消えるので、何度でも実行できます。

プロジェクトの全ファイルを表示
cs
using System.Text.Json;
using Microsoft.EntityFrameworkCore;

var postgresSql = args.Contains("--postgres-sql");
var options = new DbContextOptionsBuilder<TodoDbContext>();
if (postgresSql)
    options.UseNpgsql("Host=localhost;Database=translation_only");
else
    options.UseSqlite("Data Source=:memory:");

await using var db = new TodoDbContext(options.Options);
var categoryId = 1;
var query = db.Todos.AsNoTracking()
    .Where(t => t.CategoryId == categoryId && !t.Done)
    .OrderBy(t => t.Title).ThenBy(t => t.Id)
    .Skip(0).Take(2)
    .Select(t => new { t.Id, t.Title, Category = t.Category.Name });

// Translate queries only; do not connect to PostgreSQL or create tables.
if (postgresSql)
{
    Console.WriteLine(query.ToQueryString());
    return;
}

// The in-memory database disappears when the connection closes; each run starts with the same data.
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
db.Categories.AddRange(new Category { Id = 1, Name = "Work" }, new Category { Id = 2, Name = "Life" });
db.Todos.AddRange(
    new Todo { Id = 1, Title = "Write report", CategoryId = 1 },
    new Todo { Id = 2, Title = "Review PR", Done = true, CategoryId = 1 },
    new Todo { Id = 3, Title = "Buy milk", CategoryId = 2 });
await db.SaveChangesAsync();
db.ChangeTracker.Clear();

Print("Filter and projection", await query.ToListAsync());
Print("Second page", await db.Todos.AsNoTracking().OrderBy(t => t.Id)
    .Skip(2).Take(2).Select(t => new { t.Id, t.Title }).ToListAsync());
Print("Incomplete count", await db.Todos.CountAsync(t => !t.Done));
Print("Any incomplete tasks?", await db.Todos.AnyAsync(t => !t.Done));
Print("First item", (await db.Todos.AsNoTracking().OrderBy(t => t.Id).FirstOrDefaultAsync())?.Id);
Print("Missing ID", (await db.Todos.AsNoTracking().SingleOrDefaultAsync(t => t.Id == 99))?.Id);

var category = await db.Categories.AsNoTracking().Include(c => c.Todos)
    .SingleAsync(c => c.Id == 1);
Print("Category and tasks", new { category.Name, Titles = category.Todos.OrderBy(t => t.Id).Select(t => t.Title) });

var todo = await db.Todos.FindAsync(1) ?? throw new InvalidOperationException("The initial todo is missing.");
todo.Done = true;
Print("Database Done before save", await db.Todos.Where(t => t.Id == 1).Select(t => t.Done).SingleAsync());
await db.SaveChangesAsync();
Print("Database Done after save", await db.Todos.Where(t => t.Id == 1).Select(t => t.Done).SingleAsync());

var created = new Todo { Title = "Learn EF Core", CategoryId = 2 };
db.Todos.Add(created);
Print("Count after Add", await db.Todos.CountAsync());
await db.SaveChangesAsync();
Print("New ID", created.Id);
Print("Count after save", await db.Todos.CountAsync());

db.Todos.Remove(created);
await db.SaveChangesAsync();
Print("Count after delete", await db.Todos.CountAsync());

static void Print<T>(string label, T value) =>
    Console.WriteLine($"{label}: {JsonSerializer.Serialize(value, JsonSerializerOptions.Web)}");
cs
using Microsoft.EntityFrameworkCore;

public class Todo
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool Done { get; set; }
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Todo> Todos { get; set; } = [];
}

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();
}
xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
  </ItemGroup>
</Project>

データベースサービスをインストールせずに、リポジトリのルートから実行します。

bash
cd samples/efcore-sql-cheatsheet
dotnet run

期待される出力:

実行結果
text
Filter and projection: [{"id":1,"title":"Write report","category":"Work"}]
Second page: [{"id":3,"title":"Buy milk"}]
Incomplete count: 2
Any incomplete tasks?: true
First item: 1
Missing ID: null
Category and tasks: {"name":"Work","titles":["Write report","Review PR"]}
Database Done before save: false
Database Done after save: true
Count after Add: 3
New ID: 4
Count after save: 4
Count after delete: 3

初期データは第16章と同じです。Work カテゴリーには Write report(未完了)と Review PR(完了)、Life カテゴリーには Buy milk(未完了)があり、ID は順に 1、2、3 です。

よく使うクエリ ​

次の表は EF Core によるデータベースクエリを対象としています。ToListAsync() を呼び出した後のコレクションはすでにメモリ上にあるため、その後の Where は C# で絞り込まれ、先ほどの SQL に追加されることはありません。

要件LINQ / EF CorePostgreSQL での同等の書き方
未完了のタスクWhere(t => !t.Done)WHERE NOT "Done"
特定カテゴリーに属する未完了タスクWhere(t => t.CategoryId == categoryId && !t.Done)WHERE "CategoryId" = 1 AND NOT "Done"
タイトルだけを取得Select(t => t.Title)SELECT "Title"
タイトルの昇順。同じ場合は ID 順OrderBy(t => t.Title).ThenBy(t => t.Id)ORDER BY "Title", "Id"
ID の降順OrderByDescending(t => t.Id)ORDER BY "Id" DESC
1ページ2件で2ページ目を取得OrderBy(t => t.Id).Skip(2).Take(2)ORDER BY "Id" LIMIT 2 OFFSET 2
未完了の件数を数えるCountAsync(t => !t.Done)SELECT COUNT(*) FROM "Todos" WHERE NOT "Done"
未完了のタスクがあるか確認AnyAsync(t => !t.Done)SELECT EXISTS (SELECT 1 FROM "Todos" WHERE NOT "Done")

このサンプルでは絞り込み、並べ替え、ページング、プロジェクションを組み合わせ、Work カテゴリー内の未完了タスクについて、ID、タイトル、カテゴリー名を取得します。C# では Skip を Take より前に書きますが、SQL では通常 LIMIT … OFFSET … の順になります。各操作の意味を理解すれば十分で、呼び出し順をそのまま暗記する必要はありません。

ThenBy は追加の並べ替え条件を加えます。OrderBy を続けて2回呼び出すと、主要な並べ替え条件が上書きされるため、「まずタイトル、次に ID」という指定には使えません。ページングでは一意な Id を最後の条件にして同名レコードの順序を決め、結果が不定にならないようにします。ただし、ページをめくる間にデータが変わると、項目の抜けや重複が起きる可能性は残ります。PostgreSQL のページングに関する説明

ヒント

データがあるかだけ確認する場合は、すべてのレコードを ToListAsync() で取得せず、AnyAsync() を使います。件数が必要な場合は CountAsync() を使います。

1件を取得する:First、Single、Find ​

次の表にある「見つからない場合は null」は、この例のエンティティオブジェクトを対象とします。整数などの値型を問い合わせる場合、OrDefault はその型の既定値を返します。

メソッド見つからない場合複数見つかった場合用途
FirstOrDefaultAsync()null先頭の1件を取得一意である必要はなく、明確な順序に従って1件取得する
SingleOrDefaultAsync()null例外をスロービジネス上、最大1件に一致するはずの場合
SingleAsync()例外をスロー例外をスローサンプル内の固定カテゴリーのように、必ず1件だけ一致するはずの場合
FindAsync(id)null主キーで検索するため、複数には一致しない主キーが分かっており、コンテキストが追跡中のオブジェクトを再利用してよい場合

FirstOrDefaultAsync() はよく LIMIT 1 に対応します。SingleOrDefaultAsync() は「複数一致したかどうか」を判定する必要があるため、プロバイダーは通常最大2件を取得して件数を確認します。別の LIMIT 1 として扱うことはできません。

FindAsync はまず現在のコンテキストがその主キーのエンティティを追跡しているかを確認します。追跡中ならそのオブジェクトを直接返し、そうでなければデータベースを検索します。そのため、「必ずデータベースから読み直す」メソッドではありません。主キーによる検索

リレーション:Select と Include の違い ​

このサンプルには、次の2つの異なる要件があります。

  • カテゴリー名だけが必要:Select 内で t.Category.Name を参照すると、必要な列だけをデータベースから取得できます。先にカテゴリーオブジェクトを Include する必要はありません。
  • カテゴリーとそのタスクオブジェクトが必要:Include(c => c.Todos) でタスクのコレクションを読み込み、その後 category.Todos を列挙できます。

1回のクエリで後者を実行すると、LEFT JOIN によってカテゴリーとタスクを取得できます。ただし、SQL の結果は平坦な行で返されるため、EF Core が複数行を1つの Category とその Todos コレクションに組み立てます。Include は関連オブジェクトを読み込む指定であり、固定の SQL キーワードを意味するものではありません。

AsSplitQuery() を設定すると、コレクションの読み込みは複数の SQL に分割できます。そのため、「Include 1回につき JOIN 1回」と覚えてはいけません。これは後の最適化で扱う内容です。まずはこの例の単一クエリを理解してください。単一クエリと分割クエリ

SQL が実行されるタイミング ​

操作その場でデータベースにアクセスするか
Where、Select、OrderBy、Skip、Take、Includeしない。クエリを組み立てるだけ
ToListAsync、FirstOrDefaultAsync、SingleOrDefaultAsync、AnyAsync、CountAsyncする。クエリを実行する
FindAsync現在のコンテキストがこの主キーを追跡していれば、データベースへのクエリは不要
AsNoTrackingしない。クエリ結果を変更追跡に加えるかどうかを制御するもので、対応する SQL 句はない
ToQueryString確認用の SQL を生成するだけで、実行しない
この例のエンティティのプロパティ変更、Add、Removeすぐには書き込まず、SaveChangesAsync まで待つ

この例では Todo 1 の Done を true に変更した後、Select(t => t.Done) でデータベースを再度問い合わせます。保存前の結果は false のままで、保存後に true になります。データベース上の値を読み取るために、ここでは単一の真偽値を選択しています。追跡中のエンティティをそのまま再検索すると、メモリ上のオブジェクトが再利用されることがあります。追跡クエリ

AsNoTracking() は読み取り専用クエリに適していますが、データベースの権限設定ではなく、その後の書き込みを禁止するものでもありません。追跡されていないオブジェクトのプロパティだけを変更して SaveChangesAsync() を呼び出しても、EF Core は変更を自動的には認識しません。

追加・削除・更新と SaveChanges ​

C# での操作保存時に対応する SQL 操作
Add(created) を呼んで保存INSERT INTO … RETURNING "Id"。データベースが生成した ID を取得
追跡中のエンティティの Done を変更して保存UPDATE "Todos" SET "Done" = TRUE WHERE "Id" = 1
Remove(created) を呼んで保存DELETE FROM "Todos" WHERE "Id" = 4

実際に生成される SQL には、パラメーターや戻り値、同時実行性の確認条件が含まれる場合もあります。表では、この例での操作結果だけを示しています。

SaveChangesAsync() は、コンテキストにある保存待ちの変更をすべて保存します。直前に変更したオブジェクトだけを保存するわけではありません。そのため、この例では Add の後にデータベースを問い合わせても3件のままで、保存後に4件になります。API 全体のリクエスト検証とステータスコードについては第17章を参照してください。

技術詳細

EF Core には ExecuteUpdateAsync() と ExecuteDeleteAsync() もあり、エンティティを先に読み込まず、SaveChangesAsync() を待たずに一括更新や削除を実行できます。これらはコンテキスト内ですでに追跡されているオブジェクトを同期しません。このページでは第17章で使う追跡と保存の方法を扱います。一括更新と削除

Npgsql が実際に生成する SQL ​

このプロジェクトは PostgreSQL プロバイダーの Npgsql も参照しています。同じディレクトリで次を実行してください。

bash
dotnet run -- --postgres-sql

UseNpgsql を使って冒頭のクエリを SQL に変換し、ToQueryString() で出力します。プログラムは PostgreSQL に接続せず、テーブルも作成しません。プロジェクトで固定されている依存パッケージのバージョンでは、次のように出力されます。

Npgsql クエリのプレビュー
sql
-- @categoryId='1'
-- @p2='2'
-- @p='0'
SELECT t0."Id", t0."Title", c."Name" AS "Category"
FROM (
    SELECT t."Id", t."CategoryId", t."Title"
    FROM "Todos" AS t
    WHERE t."CategoryId" = @categoryId AND NOT (t."Done")
    ORDER BY t."Title", t."Id"
    LIMIT @p2 OFFSET @p
) AS t0
INNER JOIN "Categories" AS c ON t0."CategoryId" = c."Id"
ORDER BY t0."Title", t0."Id"

プロバイダーがサブクエリを使い、カテゴリー ID やページングの値をパラメーターにしていることが分かります。上の表にある SQL を機械的に組み合わせているわけではありません。

注意

ToQueryString() の結果はデバッグ用プレビューです。冒頭のパラメーターコメントは PostgreSQL の変数宣言ではありません。@categoryId を含むテキスト全体を psql に貼り付けて実行することはできません。実際に実行されたコマンドはログで確認してください。詳しくは第16章を参照してください。ToQueryString の説明

同じ名前の C# メソッドでも、異なるデータベースで同じ変換が行われるとは限りません。たとえば PostgreSQL の ILIKE は、Npgsql の EF.Functions.ILike で利用できますが、このチュートリアルで使う SQLite プロバイダー共通の機能ではありません。Npgsql の変換一覧

PostgreSQL で同等のスクリプトを実行する ​

次は独立した SQL の対照用ファイルです。同じ初期データを作り、順に検索、更新、追加、削除を行います。現在のセッションに一時テーブルを作成し、最後にロールバックするため、既存の業務テーブルは変更しません。

PostgreSQL スクリプト全体を表示
PostgreSql.sql
sql
-- PostgreSQL comparison: run this entire file in one connection.
-- Temporary tables are visible only in this session; the final rollback leaves existing business tables unchanged.
BEGIN;
CREATE TEMP TABLE "Categories" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    "Name" text NOT NULL
) ON COMMIT DROP;
CREATE TEMP TABLE "Todos" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    "Title" text NOT NULL,
    "Done" boolean NOT NULL DEFAULT FALSE,
    "CategoryId" integer NOT NULL REFERENCES "Categories" ("Id")
) ON COMMIT DROP;

INSERT INTO "Categories" ("Name") VALUES ('Work'), ('Life');
INSERT INTO "Todos" ("Title", "Done", "CategoryId") VALUES
    ('Write report', FALSE, 1),
    ('Review PR', TRUE, 1),
    ('Buy milk', FALSE, 2);

-- Where + OrderBy + ThenBy + Skip + Take + Select
SELECT t."Id", t."Title", c."Name" AS "Category"
FROM "Todos" AS t
JOIN "Categories" AS c ON t."CategoryId" = c."Id"
WHERE t."CategoryId" = 1 AND NOT t."Done"
ORDER BY t."Title", t."Id"
LIMIT 2 OFFSET 0;

-- Second page: two rows per page, with a stable order first
SELECT "Id", "Title" FROM "Todos" ORDER BY "Id" LIMIT 2 OFFSET 2;

-- CountAsync / AnyAsync
SELECT COUNT(*) FROM "Todos" WHERE NOT "Done";
SELECT EXISTS (SELECT 1 FROM "Todos" WHERE NOT "Done");

-- FirstOrDefaultAsync: SQL returns zero rows when no record matches; EF Core returns null.
SELECT * FROM "Todos" ORDER BY "Id" LIMIT 1;

-- SingleOrDefaultAsync: fetch at most two rows to check whether multiple records matched.
SELECT * FROM "Todos" WHERE "Id" = 99 LIMIT 2;

-- Single-query Include example: SQL returns flat rows; EF Core assembles the category and its task collection.
SELECT c."Id", c."Name", t."Id" AS "TodoId", t."Title"
FROM "Categories" AS c
LEFT JOIN "Todos" AS t ON c."Id" = t."CategoryId"
WHERE c."Id" = 1
ORDER BY c."Id", t."Id";

-- FindAsync: query the database only when the current context is not already tracking this primary key.
SELECT * FROM "Todos" WHERE "Id" = 1 LIMIT 1;

-- Equivalent operation for changing a tracked property and calling SaveChangesAsync
UPDATE "Todos" SET "Done" = TRUE WHERE "Id" = 1;
SELECT "Done" FROM "Todos" WHERE "Id" = 1;

-- Equivalent operation for Add + SaveChangesAsync
INSERT INTO "Todos" ("Title", "Done", "CategoryId")
VALUES ('Learn EF Core', FALSE, 2)
RETURNING "Id";
SELECT COUNT(*) FROM "Todos";

-- Equivalent operation for Remove + SaveChangesAsync; the new task has ID 4 in this fixed data set.
DELETE FROM "Todos" WHERE "Id" = 4;
SELECT COUNT(*) FROM "Todos";
ROLLBACK;

PostgreSQL をインストール済みの場合は、サンプルのディレクトリから実行してください。ホスト、ユーザー、データベース名は環境に合わせて変更します。パスワードは psql のプロンプトで入力します。

bash
psql -X -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -f PostgreSql.sql

データベースツールで同じ接続を使ってファイル全体を実行することもできます。主な結果は次のとおりです。

操作結果
Work の未完了タスク1 / Write report / Work
2ページ目3 / Buy milk
未完了の件数、存在するか2、true(psql では t と表示)
ID 990行。これは EF が null を返す前のデータベース上の結果です
Work とタスクのリレーション2行。Write report と Review PR に対応
更新後の Donetrue(t)
タスクの追加ID 4 を返し、合計件数は4
追加したタスクの削除合計件数は3に戻る

スクリプトの二重引用符は Todos や CategoryId などの大文字小文字を保持しており、この例の既定のマッピング名と一致します。PostgreSQL では引用符なしの識別子は小文字に変換されるため、"Todos" と todos を混在させないでください。識別子の規則

覚えておきたい5つのポイント ​

  • LINQ はまずクエリを組み立て、一覧、単一項目、集計結果を取得する段階で実行します。
  • ページングでは先に順序を固定します。ThenBy は条件を追加し、OrderBy は主要な並べ替えを指定し直します。
  • 関連するフィールドだけが必要ならプロジェクションを使い、関連オブジェクトが必要な場合に Include を検討します。
  • 変更追跡、Add、Remove、保存はそれぞれ別の段階です。メモリ上のオブジェクトを変更しただけでは、データベースには書き込まれません。
  • PostgreSQL の同等例は理解の助けになります。実際の変換は Npgsql で確認し、実行結果はデータベースで検証します。

チュートリアルに戻る:15 EF Core 入門 · 16 リレーションとクエリ · 17 CRUD 全体。FastAPI ↔ ASP.NET Core 対照表も参照できます。

.NET 10 と Minimal API を使用 · 各章に実行可能なサンプルを用意