vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【ASP.NET Core】【Entity Framework Core】Code first / DB first ってみる

はじめに

Entity Framework Core でコードからデータベースのテーブルを生成する Code first、データベースからコードを生成する DB first をやってみたよというお話。

Environments

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 でパッケージを追加しておきます。

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

コマンドのオプションについて

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