データベースのマイグレーション
第15章では EnsureCreated() を使って学習用データベースを手早く作成しました。アプリにデータが保存された後でエンティティにプロパティを追加しても、既存のテーブルは変更されません。この場合はマイグレーション(migration)が必要です。マイグレーションは、テーブル構造の変更をレビュー、コミット、実行できるコードとして記録します。
第23章には完成したサンプルがあります。次は Todos テーブルに Note 列を追加するために EF Core ツールが生成したマイグレーションファイルです。
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Deployment.Data.Migrations
{
/// <inheritdoc />
public partial class AddTodoNote : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Note",
table: "Todos",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Note",
table: "Todos");
}
}
}対応するエンティティの全ファイルです。
namespace TodoApi.Features.Todos;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public string? Note { 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; } = [];
}Up は更新を、Down は取り消しを表します。今回は null 許容列を追加するため、既存タスクの Note は null になります。このマイグレーションを取り消すと列全体が削除され、すでに入力したメモも失われます。スキーマを戻しても、データが復元されるわけではありません。
リポジトリのマイグレーションを実行する
リポジトリのルートディレクトリで次を実行します。
cd samples/23-deployment
dotnet tool restore
dotnet ef migrations list
dotnet ef database updateこの章ではローカルツールマニフェストで dotnet-ef のバージョンを固定しています。新しい todos-23.db に初めて更新を適用すると、InitialCreate と AddTodoNote の2つのマイグレーションが実行され、カテゴリーとタスクのテーブルが作成されて、固定のカテゴリーが2件追加されます。再度実行してもテーブルは重複して作成されません。
ツールの出力に表示されるマイグレーション名にはタイムスタンプが含まれます。完全な名前は Data/Migrations 内のファイルで確認してください。データベースの __EFMigrationsHistory テーブルには、正常に適用されたマイグレーションが記録されます。
リポジトリに含まれるマイグレーションを重複生成しない
InitialCreate と AddTodoNote はすでにサンプルにコミットされています。そのまま実行してください。エンティティの構造を再度変更した場合に限り、dotnet ef migrations add で新しいマイグレーションを生成し、ファイルをレビューしてください。
ツールはデザイン時ファクトリーを使ってコンテキストを作成するため、先に API を起動したり、本番用の認証サービスを構成したりする必要はありません。
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TodoApi.Data;
// dotnet ef only needs a context; authentication and the HTTP service do not need to start.
public class TodoDbContextFactory : IDesignTimeDbContextFactory<TodoDbContext>
{
public TodoDbContext CreateDbContext(string[] args)
{
var config = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json").AddEnvironmentVariables().AddCommandLine(args).Build();
var connectionString = config.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
return new TodoDbContext(new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(connectionString).Options);
}
}ここでいうデザイン時(design time)とは、EF コマンドを実行してマイグレーションを生成または適用する段階です。通常の HTTP リクエスト処理では、アプリは引き続き依存性注入コンテナーからコンテキストを取得します。
既存データを確認する方法
マイグレーションコマンドが成功しただけで、更新によってデータに影響がなかったと判断してはいけません。次の完全なテストでは、旧スキーマでテーブルを作成してタスクを挿入し、その後に列追加のマイグレーションを適用します。
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
public class MigrationTests
{
[Fact]
public async Task Adding_note_keeps_existing_rows()
{
var ct = TestContext.Current.CancellationToken;
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(ct);
await using var db = new TodoDbContext(new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(connection).Options);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("InitialCreate", ct);
var title = "Created before upgrade";
await db.Database.ExecuteSqlAsync(
$"INSERT INTO Todos (Title, Done, CategoryId) VALUES ({title}, {false}, {1})", ct);
await migrator.MigrateAsync(cancellationToken: ct);
var todo = await db.Todos.SingleAsync(ct);
Assert.Equal(title, todo.Title);
Assert.Null(todo.Note);
Assert.Equal(2, await db.Categories.CountAsync(ct));
todo.Note = "Added after upgrade";
await db.SaveChangesAsync(ct);
await migrator.MigrateAsync(cancellationToken: ct);
Assert.Single(await db.Todos.AsNoTracking().ToListAsync(ct));
Assert.Equal("Added after upgrade", await db.Todos.Select(t => t.Note).SingleAsync(ct));
}
}この章のサンプルディレクトリから次を実行します。
dotnet test --project Tests/TodoApi.Tests.csproj -c Release -p:TreatWarningsAsErrors=true合計13件のテストがすべて成功するはずです。マイグレーションのテストでは、既存のタスクとカテゴリーが残っていること、新しい列の初期値が null であることを確認します。その後メモを書き込み、再度マイグレーションを実行しても、レコードの重複作成やメモの削除が起きないことを確認します。
データベースを変更するときの手順
- エンティティまたはモデルの構成を変更し、新しいスキーマを表現します。
- 今回の変更内容を説明する名前を付けて
dotnet ef migrations addを実行し、マイグレーションを生成します。 Up、Down、モデルスナップショットを確認し、テスト用データベースで検証します。ツールが「旧列の削除と新列の追加」を検出した場合、生成される操作が意図した名前変更になるとは限りません。- マイグレーションファイルをアプリケーションコードと一緒にコミットします。本番で実行する前にデータをバックアップし、更新のタイミングを計画します。
モデルスナップショットには前回のマイグレーション後のモデルが保存されます。ツールはその内容と現在のモデルを比較して、次の変更を生成します。不整合を「直す」ためにスナップショットを手動で削除しないでください。
第23章では独立した --migrate コマンドで更新を実行し、成功してからサービスを起動します。実際のシステムでは SQL スクリプトのレビューやマイグレーションバンドルの利用もできます。デプロイ方法やデータベースプロバイダーによって選択肢が異なります。詳しくは公式のマイグレーション適用ガイドを参照してください。
EnsureCreated を使った既存データベースの場合
EnsureCreated() はマイグレーション履歴を作成しません。そのようなデータベースに InitialCreate を直接適用すると、通常はテーブルがすでに存在するため失敗します。
このチュートリアルでは、第23章に専用の新しいデータベースファイルを使うため、前の章のデータを上書きしません。古いデータベースに保持すべき情報がある場合は、まずバックアップを取り、データのインポートまたはマイグレーションのベースライン作成を計画して、複製データベースで練習してください。古いデータベースを削除したり、マイグレーション履歴を偽造したりして、この手順を飛ばしてはいけません。それぞれの適用範囲は公式のデータベース作成に関する説明を参照してください。
まとめ
- マイグレーションはテーブル構造の変更を記録します。
EnsureCreatedはマイグレーションを必要としないデータベースの作成に適しています。 - モデルを変更したらマイグレーションを生成し、レビューしてから適用します。マイグレーションとスナップショットは一緒にコミットしてください。
- 空のデータベースだけでなく、既存のレコードを使って更新を検証します。
- null 許容列の追加なら既存の行を保てますが、列の削除などではデータが失われます。
- 本番更新の前にバックアップを取り、アプリが新しいスキーマを使い始める前に更新を計画します。
