【ASP.NET Core】【Entity Framework Core】Code first / DB first ってみる
はじめに
Entity Framework Core でコードからデータベースのテーブルを生成する Code first、データベースからコードを生成する DB first をやってみたよというお話。
Environments
- .NET 5: ver.5.0.100-preview.7.20366.6
- Microsoft.EntityFrameworkCore: ver.5.0.0-preview.7.20365.15
- Microsoft.EntityFrameworkCore.Design: ver.5.0.0-preview.7.20365.15
- Npgsql.EntityFrameworkCore.PostgreSQL: ver.5.0.0-preview7-ci.20200722t163648
- Microsoft.EntityFrameworkCore.Abstractions: ver.5.0.0-preview.7.20365.15
- Microsoft.EntityFrameworkCore.Relational: ver.5.0.0-preview.7.20365.15
- Microsoft.AspNetCore.Mvc.NewtonsoftJson: ver.5.0.0-preview.7.20365.19
※Microsoft.EntityFrameworkCore.Abstractions と Microsoft.EntityFrameworkCore.Relational は Microsoft.EntityFrameworkCore (だったと思う)が依存しているとリストア時にエラーを出したので追加しました。
※個人的な興味から .NET 5 を使ってますが、現状(2020-07-24 時点)では .NET Core 3.1 と書き方などほとんど変わっていないと思います。
Code first
ではまず Code first から。
プロジェクトを作る
空で ASP.NET Core のプロジェクトを作ります。
dotnet new empty -n CodeFiirstSample
NuGet でパッケージを追加しておきます。
- Microsoft.EntityFrameworkCore
- Npgsql.EntityFrameworkCore.PostgreSQL
- Microsoft.EntityFrameworkCore.Abstractions
- Microsoft.EntityFrameworkCore.Relational
Samples
接続文字列やテーブルに対応するクラスなどを追加します。
appsettings.Development.json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "ConnectionStrings": "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX" }
Models/Workflow.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Models
{
[Table("Workflow")]
public class Workflow
{
// 主キー
[Key]
// Auto increament
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set;}
// not null
[Required]
public int ProductId { get; set; }
[Required]
[Column("CreateUserMail", TypeName="text")]
public string CreateUserMail { get; set;}
[Column(TypeName="timestamp with time zone")]
public DateTime? CirculationLimit { get; set; }
[Column(TypeName="timestamp with time zone")]
// 更新のたびに現在時刻を入れたい
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
public DateTime LastUpdateDate { get; set; }
public List<WorkflowReader> Readers {get; set; }
}
}
Models/WorkflowReader.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Models
{
public class WorkflowReader
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
// 外部キー
[ForeignKey("Workflow")]
[Required]
public int WorkflowId { get; set; }
[Required]
public string Name{ get; set; }
public Workflow Workflow {get; set; }
}
}
Models/CodeFirstSampleContext.cs
using Microsoft.EntityFrameworkCore;
namespace Models
{
public class CodeFirstSampleContext: DbContext
{
public CodeFirstSampleContext(DbContextOptions<CodeFirstSampleContext> options)
: base(options)
{
}
public DbSet<Workflow> Workflows { get; set; }
public DbSet<WorkflowReader> WorkflowReaders { get; set; }
}
}
Startup.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Models;
using Workflow;
namespace CodeFirstSample
{
public class Startup
{
private readonly IConfiguration configuration;
public Startup(IHostEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, true)
.AddEnvironmentVariables();
configuration = builder.Build();
}
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddDbContext<ProofreadingWorkflowContext>(options =>
options.UseNpgsql(configuration["ConnectionStrings"]));
services.AddScoped<IWorkflowService, WorkflowService>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
最初のマイグレーションファイルを生成する
さてさて準備ができたところでマイグレーションファイルを生成しますよ。
dotnet tool update --global dotnet-ef --version 5.0.0-preview.7.20365.15
※.NET 5 で試す場合、バージョン指定しないと最新の安定板がインストールされ、コマンド実行時にエラーが出るので注意が必要です。
ドキュメントによると、「dotnet add package Microsoft.EntityFrameworkCore.Design」で「Microsoft.EntityFrameworkCore.Design」をインストール、ということなのですが、これでインストールされるのは安定板 (3.1.6) なので、 NuGet でインストールする必要があります。
(VSCode の NuGet Package Manager 便利ですね)
前置きが長くなりましたが、マイグレーションファイルを作成します。
dotnet ef migrations add InitialCreate
コマンドを実行すると、 Migrations というフォルダ以下に下記が出力されます。
- 20200719144103_InitialCreate.cs
- 20200719144103_InitialCreate.Designer.cs
- WorkflowSampleContextModelSnapshot.cs
コマンドを実行してデータベースに反映します。
dotnet ef database update
すでに同名のテーブルが存在する場合
普通にエラーになります。
ということで、 Code first の場合はデータベースを空で作成する必要があります。
(それが難しい場合は後述の DB first で進めるのが良さそうです)
マイグレーションの取り消し
データベースに反映したマイグレーションを取り消したい場合。
例えば上記のあと何らかのマイグレーションを実行し、それを取り消したい場合は下記のようにします。
dotnet ef database update InitialCreate
直前の処理をなかったことにする、というよりは、戻したい状態を指定してそこに戻る、という方法はちょっと Git を思い出させます。
ただ、特にデータを挿入する前にテーブル名間違えたりしていた場合は、マイグレーションファイルを作って~とするより、マイグレーションファイル、テーブルを削除 -> 作り直して再度生成、とした方が楽かもしれませんね。
デフォルト値の設定
ここまでを実行すると、とりあえずテーブルは生成されます。
が、データを追加しようとすると、「Workflow」テーブルで「LastUpdateDate」が Null だと怒られます。
というのも、「DatabaseGenerated」がつけられている要素は Entity Framework が生成する INSERT 文から外されるから + デフォルト値が設定されていないから です。
INSERT INTO "Workflow" ("CirculationLimit", "CreateUserMail", "ModelName", "ProductId", "SizaiCode")
VALUES (@p4285, @p4286, @p4287, @p4288, @p4289)
RETURNING "Id", "LastUpdateDate";
なお「Id」は(おそらく) データベースでは「serial」型として扱われるため、自動でインクリメントされた値が挿入されます。
最初「DatabaseGenerated」をつければ自動で値をセットしてくれるのかな?と思い込んでいたため、ちょっとハマりました。
デフォルト値の設定は Context クラスでできます。
Models/CodeFirstSampleContext.cs
using Microsoft.EntityFrameworkCore;
namespace Models
{
public class CodeFirstSampleContext: DbContext
{
...
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Workflow>()
.Property(w => w.LastUpdateDate)
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
...
}
}
現在時刻の設定を、ついつい DateTime.Now とかしたくなりますが、その場合「マイグレーションファイルを作った時間」がデフォルト値になってしまうので要注意です。
DB first
では、先ほど作ったデータベース、テーブルを使って DB first も試してみます。
dotnet new empty -n DbFiirstSample
NuGet で Code first と同じパッケージを入れておきます。
で、コマンドを実行します。
dotnet ef dbcontext scaffold "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX" Npgsql.EntityFrameworkCore.PostgreSQL -d -o Models -n Models
- Getting Started | Npgsql Documentation
- EF Core tools reference (.NET CLI) - EF Core | Microsoft Docs
コマンドのオプションについて
DB first でコードを生成するコマンドのオプションはいくつかありますが、その中でも下記 3 つは特に必要かな~、と思いました。
- -d(--data-annotations): 生成するモデルクラスの要素にアノテーション([Key]とか)を付けます。
- -o(--output-dir): デフォルトだとプロジェクト直下にファイルが生成されるので、これを「Models」というフォルダー以下に生成されるよう変更しています。
- -n(--namespace): 生成されるクラスの namespace を指定しています。デフォルトでは「DbFiirstSample.Models」となります。
生成されたクラス
Models/Workflow.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Models
{
public partial class Workflow
{
public Workflow()
{
WorkflowReaders = new HashSet<WorkflowReaders>();
}
[Key]
public int Id { get; set; }
public int ProductId { get; set; }
[Required]
public string CreateUserMail { get; set; }
[Column(TypeName = "timestamp with time zone")]
public DateTime? CirculationLimit { get; set; }
[Column(TypeName = "timestamp with time zone")]
public DateTime LastUpdateDate { get; set; }
[InverseProperty("Workflow")]
public virtual ICollection<WorkflowReader> WorkflowReaders { get; set; }
}
}
Models/WorkflowReaders.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Models
{
[Index(nameof(WorkflowId))]
public partial class WorkflowReaders
{
[Key]
public int Id { get; set; }
public int WorkflowId { get; set; }
[Required]
public string Name{ get; set; }
[ForeignKey(nameof(WorkflowId))]
[InverseProperty("WorkflowReaders")]
public virtual Workflow Workflow { get; set; }
}
}
Models/WorkflowSampleContext.cs
using Microsoft.EntityFrameworkCore;
namespace Models
{
public partial class WorkflowSampleContext : DbContext
{
public WorkflowSampleContext()
{
}
public WorkflowSampleContext(DbContextOptions<WorkflowSampleContext> options)
: base(options)
{
}
public virtual DbSet<WorkflowReaders> WorkflowReaders { get; set; }
public virtual DbSet<Workflow> Workflow { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX");
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
}
Code first で生成されるコードとはやはり異なっていますね。
特に「DatabaseGenerated」については反映されていないようです。
(別のオプション、もしくは別のファイルで表現されている?)