【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」については反映されていないようです。
(別のオプション、もしくは別のファイルで表現されている?)