本文へスキップ

リレーションとクエリ ​

Todo にカテゴリを追加しましょう。タスクは Work と Life のどちらに属するでしょうか。この章ではカテゴリとタスクの関係を定義し、LINQ で「Work の未完了タスク」を検索します。

この章は読み取り専用のクエリ例で、起動時に少量の固定データを用意します。前章の POST は一時的に提供せず、次章でリレーションと書き込みを組み合わせます。以下が 3 つの全ファイルです。

16-relations-queries/Program.cs
cs
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
    ?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
    await db.Database.EnsureCreatedAsync();
    if (!await db.Categories.AnyAsync())
    {
        db.Categories.AddRange(
            new Category { Name = "Work", Todos = [new Todo { Title = "Write report" }, new Todo { Title = "Review PR", Done = true }] },
            new Category { Name = "Life", Todos = [new Todo { Title = "Buy milk" }] });
        await db.SaveChangesAsync();
    }
}

app.MapGet("/todos", async (TodoDbContext db, int? categoryId, bool? done, int page = 1) =>
{
    var query = db.Todos.AsNoTracking();
    if (categoryId is not null) query = query.Where(t => t.CategoryId == categoryId);
    if (done is not null) query = query.Where(t => t.Done == done);
    var currentPage = Math.Clamp(page, 1, 10000);
    return await query.OrderBy(t => t.Id).Skip((currentPage - 1) * 2).Take(2)
        .Select(t => new { t.Id, t.Title, t.Done, Category = t.Category.Name })
        .ToListAsync();
});

app.MapGet("/categories/{id:int}", async Task<Results<Ok<CategoryResponse>, NotFound>> (int id, TodoDbContext db) =>
{
    var category = await db.Categories.AsNoTracking().Include(c => c.Todos)
        .SingleOrDefaultAsync(c => c.Id == id);
    if (category is null) return TypedResults.NotFound();
    return TypedResults.Ok(new CategoryResponse(category.Id, category.Name,
        category.Todos.OrderBy(t => t.Id).Select(t => new TodoSummary(t.Id, t.Title, t.Done)).ToList()));
});

app.Run();
16-relations-queries/Models.cs
cs
using System.ComponentModel.DataAnnotations;

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 record TodoSummary(int Id, string Title, bool Done);
public record CategoryResponse(int Id, string Name, List<TodoSummary> Todos);
16-relations-queries/TodoDbContext.cs
cs
using Microsoft.EntityFrameworkCore;

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();
}

実行して確認する ​

前章のサービスを停止し、リポジトリのルートから実行します。

bash
cd samples/16-relations-queries
dotnet run

この章は前章と同じ依存関係を使いますが、データベースは独自の todos-16.db です。初回起動時に Work と Life の 2 カテゴリと、3 件の Todo を挿入します。起動コードはカテゴリテーブルが空の場合だけデータを追加するため、再起動しても重複しません。

別のターミナルで 1 ページ目(1 ページあたり 2 件)を検索します。

bash
curl http://localhost:5080/todos
json
[{"id":1,"title":"Write report","done":false,"category":"Work"},{"id":2,"title":"Review PR","done":true,"category":"Work"}]

Work の未完了タスクだけを表示します。

bash
curl "http://localhost:5080/todos?categoryId=1&done=false"
json
[{"id":1,"title":"Write report","done":false,"category":"Work"}]

2 ページ目を検索します。

bash
curl "http://localhost:5080/todos?page=2"
json
[{"id":3,"title":"Buy milk","done":false,"category":"Life"}]

カテゴリとそのタスク全体を検索します。

bash
curl http://localhost:5080/categories/1
json
{"id":1,"name":"Work","todos":[{"id":1,"title":"Write report","done":false},{"id":2,"title":"Review PR","done":true}]}

外部キーとナビゲーションプロパティの役割 ​

一対多リレーション(one-to-many relationship)では、1 つのカテゴリに複数の Todo が属し、各 Todo は 1 つのカテゴリに属します。

メンバー役割
Todo.CategoryId外部キー(foreign key)。関連カテゴリの主キー値を保持する
Todo.CategoryTodo からカテゴリオブジェクトへアクセスするナビゲーションプロパティ(navigation property)
Category.Todosカテゴリから関連 Todo のコレクションへアクセスするナビゲーションプロパティ

EF Core は名前と型に基づいてリレーションを認識します。CategoryId は null 非許容の int なので、この例では各 Todo にカテゴリが必要です。データベースの外部キー制約により、存在しないカテゴリを参照できません。カテゴリを削除するエンドポイントはまだないため、カスケード削除の規則はここでは扱いません。

Category = null! の ! はコンパイラーの null 許容警告を抑制するだけで、カテゴリを読み込むものではありません。クエリで読み込まず、手動でも値を設定しなければ、このプロパティは null のままの場合があります。

技術詳細

初期データでは Todo を新しいカテゴリの Todos コレクションに追加してから、オブジェクト全体を保存します。EF Core が関連の保存順序を処理し、生成されたカテゴリの主キーを Todo の外部キーに設定します。カテゴリの ID を先に推測する必要はありません。規約と必須リレーションについてはEF Core の一対多リレーションを参照してください。

クエリを組み立ててから実行する ​

GET /todos の query は IQueryable<Todo> で、まだ実行されていないクエリを表します。42、43 行目ではクエリパラメーターに応じて条件を追加し、45 行目で並べ替えとページ分割を追加してから、最後に ToListAsync() を呼び出します。

先に ToList してから Where で絞り込まないのはなぜでしょうか。リストを取得してから絞り込むと、データベースのすべての行をアプリケーションのメモリへ転送することになります。この例では LINQ クエリを組み立ててから SQLite に絞り込みと並べ替えを行わせ、現在のページだけを返します。

Math.Clamp(page, 1, 10000) はページ番号を 1~10000 に制限します。1 未満は 1、大きすぎる値は 10000 として処理します。1 ページを 2 件に固定しているので、Skip(スキップ)と Take(取得)の動きが分かりやすくなっています。

ページ分割の前に一意な Id で並べ替え、同じデータの順序を固定します。ただしページを切り替えている間にデータが追加または削除されると、重複や欠落が起こる可能性は残ります。

Select:必要なフィールドだけを検索する ​

46 行目の Select は射影(projection)と呼ばれ、エンティティから Id、Title、Done、カテゴリ名を選び、レスポンスとして返します。

未実行のこのクエリでは、t.Category.Name は EF Core によって関連テーブルを参照する SQL に変換されます。カテゴリ全体を先に Include する必要はありません。また、Todo ごとにカテゴリを個別検索することもありません。

双方向のナビゲーションプロパティを持つエンティティをそのまま返さないのはなぜでしょうか。Todo は Category を参照し、Category は Todo を含みます。そのままシリアライズすると循環参照が起きやすく、データベースモデルが HTTP レスポンスと密結合になります。射影でレスポンス項目を明示しておけば、データベースにプロパティが増えても API に自動公開されません。

Include:関連オブジェクトが必要な場合に読み込む ​

GET /categories/{id} は別の要件を示します。カテゴリとその Todo オブジェクトを取得してから、レスポンスを整えます。Include(c => c.Todos) はクエリ時に関連コレクションを読み込むもので、Eager Loading(事前読み込み)と呼ばれます。この例では遅延読み込みを有効にしていません。

SingleOrDefaultAsync がクエリを実行し、見つからない場合は null を返します。ここでは一意な主キーで検索するため、カテゴリは最大 1 件です。返す前に CategoryResponse を作り、その中の TodoSummary にはカテゴリを指すナビゲーションプロパティを含めません。

データの受け渡し専用に使う型を DTO(Data Transfer Object、データ転送オブジェクト)と呼びます。前章までのリクエスト record も DTO です。データベースエンティティにプロパティを追加しても、API の JSON 項目まで変更する必要はありません。

ヒント

実際の SQL を確認するには、サービスを停止して dotnet run -- --Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command=Information を実行し、検索を送ります。ページ番号などはリクエストごとに変わるので、絞り込みが SQL 内で行われているか、余計なクエリがないかを確認しましょう。関連データ読み込みの説明

FastAPI との比較

SQLAlchemy のリレーションプロパティでオブジェクト間の関連を定義し、クエリ式で絞り込みや列の選択をする方法に似ています。Include はリレーションを事前読み込みする考え方に近いものの、ナビゲーションプロパティにアクセスするたびに SQL が自動実行されるわけではありません。

ヒント

LINQ のメソッドを確認したい場合は、EF Core / LINQ ↔ PostgreSQL クイックリファレンスを参照してください。実行可能な比較例があります。

まとめ ​

  • 外部キーは関連 ID を保持し、ナビゲーションプロパティはオブジェクト間の関係を表します。ナビゲーションプロパティの宣言は、オブジェクトの読み込みを意味しません。
  • IQueryable 上で条件、並べ替え、ページ分割を組み立て、最後に ToListAsync() などでクエリを実行します。
  • Select で射影する項目を選び、双方向のリレーションを直接シリアライズしないようにします。
  • カテゴリ名だけが必要なら直接射影し、関連オブジェクトが必要な場合に Include を使います。
  • 各ページの結果は一意キーで並べ替えます。この例ではページ番号を 1~10000 に制限しています。

次章:完全な CRUD——Todo の作成、更新、削除を実装します。前章:EF Core 入門。

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