EntityFramework Core 3.1で、int型やint?型のフィールドに対するIsRequired()の挙動を確認してみた

EntityFramework Core (以降、EF Core)では、テーブルのフィールドのNOT NULL制約を制御する方法の一つとして、Fluent APIがあります。
Entity Properties - EF Core | Microsoft Docs

そんな中、フィールドの型が intint? では、Fluent APIの挙動が異なっていたため、メモを残します。

 
目次

 

環境

  • macOS 10.13.6
  • .NET Core 3.1.0
  • EntityFramework Core 3.1.0
  • Microsoft.Extensions.Logging.Console 3.1.0
  • xunit 2.4.1

 
以下の記事の環境を、一部を除いてそのまま流用しています。
EntityFramework Core 3.1で、ClientSetNullの挙動を確認してみた - メモ的な思考的な

異なる点は、データベースを「SQL Server on Linux / PostgreSQL」から、手軽なSQLiteへの変更です。

そのため、MyAppプロジェクトに、SQLite向けのNuGetパッケージを追加しています。

$ dotnet add package Microsoft.EntityFrameworkCore.SQLite

 

確認するパターン

今回は、以下のパターンをテストコードを使って、発行されるSQLを確認します。

パターン IsRequired()対象項目の型 IsRequiredの値
NULL不許可型に対し、NOT NULL制約 int true
NULL不許可型に対し、NULL許可 int false
Nullable型に対し、NOT NULL制約 int? true
Nullable型に対し、NULL許可 int? false

 
また、外部キーのint/int?型についても確認します。

 

長いのでまとめ

int型/int?型のフィールドについては以下の結果となりました。

パターン IsRequired()対象項目の型 IsRequiredの値 結果
NULL不許可型に対し、NOT NULL制約 int true NOT NULL制約
NULL不許可型に対し、NULL許可 int false 例外が発生
Nullable型に対し、NOT NULL制約 int? true NOT NULL制約
Nullable型に対し、NULL許可 int? false NULL許可

 
また、外部キーについては以下となりました。

int型のフィールドとは異なり、int型の外部キーに対して、Fluent APIIsRequired(false) を設定しても、例外が発生しない & NOT NULL制約となりました。

外部キーの型 IsRequiredの値 結果 例外発生
int true NOT NULL制約 無し
int false NOT NULL制約 無し
int? true NOT NULL制約 無し
int? false NULL許可 無し

 

確認用の実装

エンティティクラス

3種類用意します。

int型のエンティティクラス
public class NotNullEntity
{
    public int Id { get; set; }
    public int NotNullField { get; set; }
}

 

int?型のエンティティクラス
public class NullableEntity
{
    public int Id { get; set; }
    public int? NullableField { get; set; }
}

 

int/int?型の外部キーを持つエンティティクラス群

外部キーと型については

  • 外部キーAuthorFkが、int型
  • 外部キーContributorFkが、int?型

としました。

public class User
{
    public int Id { get; set; }

    public List<Blog> AuthoredBlogs { get; set; }
    public List<Blog> ContributedToBlogs { get; set; }
}
public class Blog
{
    public int Id { get; set; }
    
    [ForeignKey("Author")]
    public int AuthorFk { get; set; }
    public User Author { get; set; }
    
    [ForeignKey("Contributor")]
    public int? ContributorFk { get; set; }
    public User Contributor { get; set; }
}

 

DbContext

前回の記事のように、

  • ログ出力部分はベースへ移動
  • 3種類のエンティティクラスに対し、それぞれDbContextを用意

としました。

 

ログ出力用のベースDbContext

LoggerFactoryを使って、コンソールへSQLの実行ログを出力しています。

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace MyApp.IsRequired
{
    public class IsRequiredBaseContext<T> : DbContext
        where T : DbContextOptions
    {
        protected bool _isRequired;
        
        public IsRequiredBaseContext(T options, bool isRequired) : base(options)
        {
            
            _isRequired = isRequired;
        }

        public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder =>
            {
                builder
                    .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
                    .AddConsole();
            });
        
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            optionsBuilder
                .EnableSensitiveDataLogging()
                .UseLoggerFactory(MyLoggerFactory);
        }
    }
}

 

int型のDbContext

NOT NULL制約を外部から指定できるよう、

  • コンストラクタで IsRequired 用の値を渡す
  • OnModelCreatingをオーバーライドし、対象のプロパティに対して Fluent APIで IsRequired 用の値を設定

とします。

using Microsoft.EntityFrameworkCore;

namespace MyApp.IsRequired
{
    public class NotNullContext : IsRequiredBaseContext<DbContextOptions<NotNullContext>>
    {
        public NotNullContext(DbContextOptions<NotNullContext> options, bool isRequired) : base(options, isRequired) {}

        public DbSet<NotNullEntity> NotNullModels { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<NotNullEntity>()
                .Property(m => m.NotNullField)
                .IsRequired(_isRequired);
        }
    }
}

 

int?型のDbContext

内容は、int型のDbContextと同じです。

using Microsoft.EntityFrameworkCore;

namespace MyApp.IsRequired
{
    public class NullableContext : IsRequiredBaseContext<DbContextOptions<NullableContext>>
    {
        public NullableContext(DbContextOptions<NullableContext> options, bool isRequired) 
            : base(options, isRequired) {}

        public DbSet<NullableEntity> NullableModels { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<NullableEntity>()
                .Property(m => m.NullableField)
                .IsRequired(_isRequired);
        }
    }
}

 

int/int?型の外部キーを持つDbContext

外部キー(AuthorFk/ContributorFk)に対する NOT NULL制約の制御は

modelBuilder.Entity<Blog>()
    .HasOne(m => m.Author)
    .WithMany(m => m.AuthoredBlogs)
    .IsRequired(_isRequired);

にて行います。

全体像はこちら。

using Microsoft.EntityFrameworkCore;

namespace MyApp.IsRequired
{
    public class UserBlogContext : IsRequiredBaseContext<DbContextOptions<UserBlogContext>>
    {
        public UserBlogContext(DbContextOptions<UserBlogContext> options, bool isRequired) 
            : base(options, isRequired) {}

        public DbSet<User> NotNullUsers { get; set; }
        public DbSet<Blog> NotNullBlogs { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Blog>()
                .HasOne(m => m.Author)
                .WithMany(m => m.AuthoredBlogs)
                .IsRequired(_isRequired);
            
            modelBuilder.Entity<Blog>()
                .HasOne(m => m.Contributor)
                .WithMany(m => m.ContributedToBlogs)
                .IsRequired(_isRequired);
        }
    }
}

 

結果を確認するテストコード

DbContextを生成する部分は、3種類のDbContextがあっても型だけが違うので、ジェネリックを使っています。

なお、ジェネリックでは、引数を持つコンストラクタは利用できません。

そのため、以下を参考にして、 Activator.CreateInstance() にてジェネリッククラスのインスタンス化をします。
ジェネリックスで引数を持つコンストラクタを使用してインスタンスを生成する - Qiita

ただし、 Activator.CreateInstance() はパフォーマンス問題があるため注意が必要です。今回は検証するだけなので、気にしないことにします。
補足: new() 制約 | ジェネリック - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

using System;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using MyApp.IsRequired;
using Xunit;

namespace MyApp.Test.IsRequiredTest
{
    public enum Pattern
    {
        NotNull,
        Nullable,
        Fk
    }

    public class IsRequiredFieldTest
    {
        [InlineData("NotNull型int - IsRequiredが true の場合", Pattern.NotNull, true)]
        // [InlineData("NotNull型int - IsRequiredが false の場合", Pattern.NotNull, false)]
        // [InlineData("Nullable型int? - IsRequiredが true の場合", Pattern.Nullable, true)]
        // [InlineData("Nullable型int? - IsRequiredが false の場合", Pattern.Nullable, false)]
        // [InlineData("int/int?のFK - IsRequiredが true の場合", Pattern.Fk, true)]
        // [InlineData("int/int?のFK - IsRequiredが false の場合", Pattern.Fk, false)]
        [Theory]
        public void Nullable型やNotNull型のIsRequiredテスト(string title, Pattern pattern, bool isRequired)
        {
            Console.WriteLine($"{Environment.NewLine}==================={Environment.NewLine}" +
                              $"{title}{Environment.NewLine}" +
                              "===================");

            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            try
            {
                using DbContext context = pattern switch
                {
                    Pattern.NotNull => CreateContext<NotNullContext>(connection, isRequired),
                    Pattern.Nullable => CreateContext<NullableContext>(connection, isRequired),
                    Pattern.Fk => CreateContext<UserBlogContext>(connection, isRequired),
                    _ => throw new Exception()
                };
                context.Database.EnsureCreated();
            }
            finally
            {
                connection.Close();
            }
        }

        private T CreateContext<T>(SqliteConnection connection, bool isRequired)
            where T : DbContext
        {
            var options = new DbContextOptionsBuilder<T>()
                .EnableSensitiveDataLogging()
                .UseSqlite(connection)
                .Options;

            // Activator.CreateInstanceはパフォーマンス問題があることに注意
            // 参考:https://ufcpp.net/study/csharp/sp2_generics.html#new-constrants
            return (T) Activator.CreateInstance(typeof(T), options, isRequired);
        }
    }
}

 
このテストコードは

$ dotnet test --filter "FullyQualifiedName~MyApp.Test.IsRequiredTest.IsRequiredTest"

で実行できます。

 
以上で準備は終わりです。

 

結果

NULL不許可型に対し、NOT NULL制約

NOT NULL制約ができました。

===================
NotNull型int - IsRequiredが true の場合
===================
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NotNullModels" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NotNullModels" PRIMARY KEY AUTOINCREMENT,
          "NotNullField" INTEGER NOT NULL
      );

 

NULL不許可型に対し、NULL許可

int型にNULL許可したところ、例外が発生しました。

エラーメッセージに

the type of the property is 'int' which is not a nullable type

とあるので、int型ではNULL許可ができないようです。

===================
NotNull型int - IsRequiredが false の場合
===================
...
  X MyApp.Test.IsRequiredTest.IsRequiredTest.Nullable型やNotNull型のIsRequiredテスト(title: "NotNull型int - IsRequiredが false の場合", pattern: NotNull, isRequired: False) [526ms]
  エラー メッセージ:
   System.InvalidOperationException : The property 'NotNullField' on entity type 'NotNullEntity' cannot be marked as nullable/optional because the type of the property is 'int' which is not a nullable type. Any property can be marked as non-nullable/required, but only properties of nullable types and which are not part of primary key can be marked as nullable/optional.

 

Nullable型に対し、NOT NULL制約

int?型でも NOT NULL 制約ができました。

===================
Nullable型int? - IsRequiredが true の場合
===================
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NullableModels" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NullableModels" PRIMARY KEY AUTOINCREMENT,
          "NullableField" INTEGER NOT NULL
      );

 

Nullable型に対し、NULL許可

int?型であれば、NULL許可できました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NullableModels" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NullableModels" PRIMARY KEY AUTOINCREMENT,
          "NullableField" INTEGER NULL
      );

 

外部キー int型/int?型で、NOT NULL制約

int型/int?型ともに、NOT NULL制約ができました。

===================
int/int?のFK - IsRequiredが true の場合
===================
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NotNullUsers" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NotNullUsers" PRIMARY KEY AUTOINCREMENT
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NotNullBlogs" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NotNullBlogs" PRIMARY KEY AUTOINCREMENT,
          "AuthorFk" INTEGER NOT NULL,
          "ContributorFk" INTEGER NOT NULL,
          CONSTRAINT "FK_NotNullBlogs_NotNullUsers_AuthorFk" 
              FOREIGN KEY ("AuthorFk") 
              REFERENCES "NotNullUsers" ("Id") ON DELETE CASCADE,
          CONSTRAINT "FK_NotNullBlogs_NotNullUsers_ContributorFk" 
              FOREIGN KEY ("ContributorFk") 
              REFERENCES "NotNullUsers" ("Id") ON DELETE CASCADE
      );

 

外部キー int型/int?型で、NULL許可

挙動に違いが出ました。

  • int型は、NOT NULL制約
  • int?型は、NULL許可

int型のフィールドの時とは異なり、int型の外部キーでは例外が出ないまま、NOT NULL制約が付きました。

===================
int/int?のFK - IsRequiredが false の場合
===================
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NotNullUsers" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NotNullUsers" PRIMARY KEY AUTOINCREMENT
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NotNullBlogs" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_NotNullBlogs" PRIMARY KEY AUTOINCREMENT,
          "AuthorFk" INTEGER NOT NULL,
          "ContributorFk" INTEGER NULL,
          CONSTRAINT "FK_NotNullBlogs_NotNullUsers_AuthorFk" 
              FOREIGN KEY ("AuthorFk") 
              REFERENCES "NotNullUsers" ("Id") ON DELETE RESTRICT,
          CONSTRAINT "FK_NotNullBlogs_NotNullUsers_ContributorFk" 
              FOREIGN KEY ("ContributorFk") 
              REFERENCES "NotNullUsers" ("Id") ON DELETE RESTRICT
      );

 

まとめ

int型/int?型のフィールドについては以下の結果となりました。

パターン IsRequired()対象項目の型 IsRequiredの値 結果
NULL不許可型に対し、NOT NULL制約 int true NOT NULL制約
NULL不許可型に対し、NULL許可 int false 例外が発生
Nullable型に対し、NOT NULL制約 int? true NOT NULL制約
Nullable型に対し、NULL許可 int? false NULL許可

 
また、外部キーについては以下となりました。

int型のフィールドとは異なり、int型の外部キーに対して、Fluent APIIsRequired(false) を設定しても、例外が発生しない & NOT NULL制約となりました。

外部キーの型 IsRequiredの値 結果 例外発生
int true NOT NULL制約 無し
int false NOT NULL制約 無し
int? true NOT NULL制約 無し
int? false NULL許可 無し

 

ソースコード

Githubに上げました。 MyApp/IsRequiredMyApp.Test/IsRequired ディレクトリが今回のものです。
https://github.com/thinkAmi-sandbox/EntityFramework_Core_Sample