EntityFramework Core 3.1で、ClientSetNullの挙動を確認してみた

EntityFramework Core(以降、EF Core)の外部キーの ON DELETE について調べたところ、 ClientSetNull の存在を知りました。
連鎖削除 - EF Core | Microsoft Docs

いつからこのような設定があるのか調べたところ、EF Core 2.0から導入されたようです。
EF Core 2.0の非互換的変更

stackoverflowを読んだところ、MS SQL ServerのON DELETEまわりでエラーが出なくなるようでした。
cascade - EF Core - why ClientSetNull is default OnDelete behavior for optional relations (rather than SetNull) - Stack Overflow

そこで今回、ClientSetNullの挙動を確認してみました。

目次

 

環境

 
なお、今回の確認は

  • Consoleアプリケーションを作成
  • そのテストコードで、ClientSetNullを使って確認

としました。

また、Dockerを用意し、SQL ServerPostgreSQLとでOn Deleteまわりの挙動の違いを確認してみます。

 

長いのでまとめ

  • ClientSetNullは ON DELETE NO ACTION だが、Nullが設定される
  • SQL Serverの場合、外部キーの状況によってはSetNullではなくClientSetNullを使う
    • multiple cascade pathsな関係を持つモデルなど
  • データベースによっては、multiple cascade pathsな関係を持つモデルも利用可能
  • On Deleteを動作させるには、関係するモデルをInclude()で読み込んでおく

 

環境構築

プロジェクト作成まで

dotnetコマンドで作成していきます。

# EFCoreSampleディレクトリの中に、slnファイルを作成
$ dotnet new sln -o EFCoreSample
cd EFCoreSample/

# MyAppプロジェクトにConsoleアプリケーションを作成
dotnet new console -o MyApp

# ソリューションに、MyAppプロジェクトを追加
dotnet sln add ./MyApp/

# プロジェクトディレクトリへ移動
cd MyApp/

 
また、Consoleアプリケーションテンプレートでは、csprojファイルのLanguage Versionが 7.3 になっていたため、 8.0 へと変更しておきます。

Riderであれば、 csproj ファイルで右クリック > コンテキストメニューProperties > ApplicationのLanguageから変更できます。

 

データベースまわりのNuGetパッケージを追加

SQLServer向けにEF Core関係のパッケージを追加します。

# マイグレーション向けのツールを追加
$ dotnet add package Microsoft.EntityFrameworkCore.Design

# SQLServer向け
$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer

 
続いて、PostgreSQL向けをインストールします。

Npgsql(.NET Access to PostgreSQL)で提供しているパッケージを使います。

$ dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

 
なお、今回はVisualStudioではなくRiderを使うため、パッケージマネージャーツール(Microsoft.EntityFrameworkCore.Tools)は不要です。
Entiy Framework Core のインストール - EF Core | Microsoft Docs

 

ログまわりのNuGetパッケージを追加

EF CoreからSQLが実行された時の内容を知りたいため、ログへの出力を考えます。

xUnit.netを使う場合、デフォルトではテストが並列で実行されるため、 Console.WriteLine ではなく ITestOutputHelper を使うとあります。

If you used xUnit.net 1.x, you may have previously been writing output to Console, Debug, or Trace. When xUnit.net v2 shipped with parallelization turned on by default, this output capture mechanism was no longer appropriate; it is impossible to know which of the many tests that could be running in parallel were responsible for writing to those shared resources. Users who are porting code from v1.x to v2.x should use one of the two new methods instead.

Capturing Output > xUnit.net

 
ITestOutputHelper での出力先はOutputとなるため、SQLの実行ログをOutputへ出力する方法を探してみました。

以下のstackoverflowはあったものの、うまく実装できませんでした。
c# - Entity Framework Core: Log queries for a single db context instance - Stack Overflow

他の方法として、 dotnet test --logger:"console;verbosity=detailed" とすることで、Outputの内容をコンソールへ出力できました。
.NET Core tests produce no output · Issue #1141 · xunit/xunit

ただ

  • テストコードで output.WriteLine するタイミング
  • SQLの実行ログを出力するタイミング

がずれてしまい、EF Coreのメソッドを実行するとどのSQLが実行されるのか分かりづらくなりました。

 
一方、 Console.WriteLine で出力すればタイミングが合っていそうでした。

そのため、現時点ではxUnit.netの ITestOutputHelper を使う方法は諦め、Console.WriteLineを使うことにしました。

一応、 [Collection] 属性を使うことでテストが直列に実行されるようでしたので、これをテストクラスへと付与することにします。
c# - Execute unit tests serially (rather than in parallel) - Stack Overflow

 
SQLのログをコンソールへ出力するためには、 AddConsole() を可能にするためのパッケージを追加します。
ASP .NET Core 3.1 + EntityFramework Core を使って、一対多のリレーションがあるデータをレスポンスしてみた - メモ的な思考的な

$ dotnet add package Microsoft.Extensions.Logging.Console

 

コンソールアプリケーションの起動確認

dotnet runHello worldができればOKです。

$ dotnet run
Hello World!

 

Dockerでのデータベース起動

SQL Server on Linux

Dockerイメージが提供されているため、これを使います。
Microsoft SQL Server - Docker Hub

今回はこんな感じで起動します。

項目 設定値
SQL Serverバージョン 2019
イメージ mcr.microsoft.com/mssql/server:2019-latest
コンテナ名 ms_sql_ef_core
接続ユーザー sa
接続ユーザーのパスワード Your_password123
接続ポート ローカルの1433ポートを、Dockerの1433ポートへ接続

コマンドはこちら。

$ docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=Your_password123' -p 1433:1433 --name ms_sql_ef_core -d mcr.microsoft.com/mssql/server:2019-latest

 
なお、Dockerを停止してコンテナを削除するコマンドはこちら。

# 確認 (表示結果は一部省略)
$ docker ps
CONTAINER ID        IMAGE                                        NAMES
6856b27fda46        mcr.microsoft.com/mssql/server:2019-latest   ms_sql_ef_core

# コンテナ停止
$ docker container stop ms_sql_ef_core

# コンテナ削除
$ docker rm ms_sql_ef_core

 
また、Riderでも接続できるか確認します。

Riderでは、 View > ToolWindows > Database を開きます。

DataSourceとして、Microsoft SQL Server を以下の内容で追加します。

項目 設定値
Name 任意(mssql_sample)
Port 1433 (Dockerにあわせる)
User sa
Password Your_password123

 
入力後、 TestConnection ボタンで接続し、以下のような感じで表示されればOKです。

DBMS: Microsoft SQL Server (ver. 15.00.2070) Case sensitivity: plain=mixed, delimited=mixed Driver: Microsoft JDBC Driver 7.4 for SQL Server (ver. 7.4.1.0, JDBC4.2) Ping: 317 ms SSL: no

 

PostgreSQL

同じく、Dockerで起動します。

項目
Host localhost
接続ポート ローカルの55432をDockerの5432に接続
接続ユーザ postgres
接続パスワード Your_password123
データベース efcore

起動と確認のコマンドです。

# 起動
docker run -e 'POSTGRES_PASSWORD=Your_password123' -e 'POSTGRES_DB=efcore' -p 55432:5432 --name postgres_ef_core -d postgres:12.1-alpine

# 確認
$ docker ps
CONTAINER ID        IMAGE
31b44774de4b        postgres:12.1-alpine

 

こちらも同じくRiderで接続できればOKです。

項目 設定値
Host localhost
Port 55432
User postgres
Password Your_password123
Database efcore

 

Consoleアプリから、EF Core経由でSQL Serverへ接続

実装

今回は HelloEfCore というディレクトリ作成し、

  • HelloEfCoreディレクトリの中に実装
  • Program.csからその実装を呼び出す

という形にします。

 

まずは、EF Coreのテーブルを作るために、いわゆるASP.NET Coreのモデルを作成します。

namespace MyApp.HelloEfCore
{
    public class HelloModel
    {
        public int Id { get; set; }
        public string Content { get; set; }
    }
}

 

続いて、DbContextを作成します。

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

namespace MyApp.HelloEfCore
{
    public class HelloContext: DbContext
    {
        public DbSet<HelloModel> HelloModels { get; set; }
        
        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)
                // Databaseは、新規に "efcore" を作成する
                .UseSqlServer(@"Server=tcp:.;Database=efcore;User=sa;Password=Your_password123;");
        }
    }
}

 
なお、SQL Serverのデータベースは、新規作成の efcore を使います。

デフォルトの master では、データベースを削除するときにエラーが出るためです。

dotnet ef database drop

Option 'SINGLE_USER' cannot be set in database 'master'.

 
また、 optionsBuilder.EnableSensitiveDataLogging() を使うことで、SQLの実行ログが

Executed DbCommand (64ms) [Parameters=[@p0='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;

から

Executed DbCommand (77ms) [Parameters=[@p0='Hello World' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;

へと、パラメータの値 @p0='Hello World' が出力されるようにしています。
DbContextOptionsBuilder.EnableSensitiveDataLogging Method (Microsoft.EntityFrameworkCore) | Microsoft Docs

 
さて、モデルとDbContextを使って、SQL Serverへデータを保存するクラスを作成します。

なお、今回は C# 8.0 のため、 using 変数宣言を使ってみます。
リソースの破棄 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

namespace MyApp.HelloEfCore
{
    public class HelloEfCore
    {
        public static void InsertMsSqlServer()
        {
            using var context = new HelloContext();
            
            context.HelloModels.Add(new HelloModel
            {
                Content = "Hello World!"
            });
            context.SaveChanges();
        }
    }
}

 
最後に、Program.csから、HelloEfCoreクラスのInsertMsSqlServer()メソッドを呼びます。

namespace MyApp
{
    class Program
    {
        static void Main(string[] args)
        {
            HelloEfCore.HelloEfCore.InsertMsSqlServer();
        }
    }
}

 

マイグレーション

dotnetコマンドを使って、マイグレーションを実行します。

使用するDbContextを明示するため、 --context オプションで HelloContext を指定します。

$ dotnet ef migrations add InitialCreate --context HelloContext
...
Done. To undo this action, use 'ef migrations remove'

 
マイグレーションファイルができたら、SQL Serverへと反映します。

$ dotnet ef database update --context HelloContext

...
Applying migration '20200102100945_InitialCreate'.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [HelloModels] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          CONSTRAINT [PK_HelloModels] PRIMARY KEY ([Id])
      );
...
Done.

 
Riderでも、SQL Server上のテーブルを確認できます。

f:id:thinkAmi:20200102213602p:plain:w400

 
続いて、コンソールアプリケーションを実行します。

$ dotnet run
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (60ms) [Parameters=[@p0='Hello World!' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [HelloModels] ([Content])
      VALUES (@p0);
      SELECT [Id]
      FROM [HelloModels]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

ログに実行されたSQLが表示されています。

Riderでも、SQL Serverの登録データを確認できます。

f:id:thinkAmi:20200102213623p:plain:w400

 
以上で、EF CoreからSQL Serverへ接続し、実行されるSQLをログに出力できる環境ができました。

 

単一FKを持つモデル間でのSetNullとClientSetNullの違い

では、本題のSetNullとClientSetNullの違いについて見ていきます。

今回、Blogに対してAuthorとしてのUserが紐づくモデルを用意します。

そして、Userを削除したときに、外部キー参照しているBlogがどうなるかを見ます。

 

モデル
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;

namespace MyApp.ClientSetNull
{
    public interface IUser<T>
    {
        string Name { get; set; }
        List<T> AuthoredBlogs { get; set; }
    }
    public interface IBlog
    {
        int Id { get; set; }
        int? AuthorId { get; set; }
    }
    
    // User - Blog 間に、FKが1つの場合
    public class SingleFkUser : IUser<SingleFkBlog>
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public List<SingleFkBlog> AuthoredBlogs { get; set; }
    }

    public class SingleFkBlog: IBlog
    {
        public int Id { get; set; }
        public string Content { get; set; }
        
        public int? AuthorId { get; set; }
        public SingleFkUser Author { get; set; }
    }
}

 

DbContext

ポイントは

  • テストコードでSetNull/ClientSetNullを切り替えられるよう、コンストラクタを追加
  • OnModelCreatingで、Fluent APIを使ってUserとBlogの関連を定義

です。

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

namespace MyApp.ClientSetNull
{
    public class SingleContext : DbContext
    {

        private readonly DeleteBehavior _deleteBehavior;
        private readonly bool _isRequired;

        // SetNull/ClientSetNullを切り替えられるよう、引数を追加したコンストラクタを用意
        public SingleContext(DbContextOptions<SingleContext> options, 
            DeleteBehavior behavior, bool isRequired) : base(options)
        {
            _deleteBehavior = behavior;
            _isRequired = isRequired;
        }

        public DbSet<SingleFkUser> SingleFkUsers { get; set; }
        public DbSet<SingleFkBlog> SingleFkBlogs { get; set; }

        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);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // コンストラクタで受け取った内容で、User - Blog 間の関連を定義
            modelBuilder.Entity<SingleFkBlog>()
                .HasOne(m => m.Author)
                .WithMany(m => m.AuthoredBlogs)
                .OnDelete(_deleteBehavior)
                .IsRequired(_isRequired);
        }
    }
}

 

テストコード
準備

今回はxUnitを使ってテストコードを書くため、環境を準備します。

まずは、slnファイルのあるディレクトリで、テストプロジェクトを作成します。

$ ls -al
-rw-r--r--  EFCoreSample.sln
-rw-r--r--  EFCoreSample.sln.DotSettings.user
drwxr-xr-x  MyApp

# xunitテンプレートを使ってプロジェクトを作成
$ dotnet new xunit -n MyApp.Test

# ソリューションに追加
$ dotnet sln add MyApp.Test/MyApp.Test.csproj 
プロジェクト `MyApp.Test/MyApp.Test.csproj` をソリューションに追加しました。

 
テストプロジェクトのディレクトリへと移動し、テストプロジェクトから、テスト対象プロジェクトへの参照を追加します。

$ cd MyApp.Test/

$ dotnet add reference ../MyApp/MyApp.csproj 
参照 `..\MyApp\MyApp.csproj` がプロジェクトに追加されました。

 

xunitテンプレートでインストールされるNuGetパッケージですが

  • xunitのバージョンは、 2.4.0
  • Microsoft.NET.Test.Sdkは、16.2.0

と、最新バージョンとは異なります。

このままではテストの周辺ツールが動作しないため、これらのパッケージをバージョンアップします。

dotnet add package コマンドでバージョンを指定しないで実行することで、最新になります。 -v でバージョンを指定しても良いです。

$ dotnet add package xunit
$ dotnet add package Microsoft.NET.Test.Sdk

 

テスト環境ができたか、動作確認をします。

テンプレートに用意されている UnitTest1.cs にテストコードを追加

public class UnitTest1
{
    [Fact]
    public void Test1()
    {
        // 以下の一行を追加
        Assert.True(true);
    }
}

 
テストコードのディレクトリで dotnet test 実行したところ、テストがパスしました。

$ dotnet test
/path/to/MyApp.Test/bin/Debug/netcoreapp3.1/MyApp.Test.dll(.NETCoreApp,Version=v3.1) のテスト実行
Microsoft (R) Test Execution Command Line Tool Version 16.3.0
Copyright (c) Microsoft Corporation.  All rights reserved.

テスト実行を開始しています。お待ちください...

合計 1 個のテスト ファイルが指定されたパターンと一致しました。
                                                                                                                                           
テストの実行に成功しました。
テストの合計数: 1
     成功: 1
合計時間: 2.5209 秒

 
確認できましたので、今後UnitTest1.Test1()は実行しないよう、スキップ設定を行います。
Comparing xUnit.net to other frameworks > xUnit.net

public class UnitTest1
{
    // Factの引数Skipを追加
    [Fact(Skip = "実施不要")]
    public void Test1()
    {
        Assert.True(true);
    }
}

 
テストを再実行すると、スキップされました。

$ dotnet test
...
合計 1 個のテスト ファイルが指定されたパターンと一致しました。
[xUnit.net 00:00:00.92]     MyApp.Test.UnitTest1.Test1 [SKIP]
 ! MyApp.Test.UnitTest1.Test1 [1ms]
                                                                                                                                           
テストの実行に成功しました。
テストの合計数: 1
    スキップ: 1
合計時間: 2.1947 秒

 

以上で環境が整いました。

 

全体像

テストコード自体はGithubにあります。
https://github.com/thinkAmi-sandbox/EntityFramework_Core_Sample/tree/master/MyApp.Test

これ以降は説明に必要なものだけ記載していきます。

 
まずは全体像です。

xUnitのTheoryを使って、DeleteBehaviorの値などを切り替えてデータベースを作成後に、検証していきます。

// Theoryですべてをテストすると、ON DELETEが一つのもの(例:NOT NULL)に制限されるので、1つずつアンコメントして試す
[InlineData("単一FK - 任意 - SetNull", DeleteBehavior.SetNull, false)]
// [InlineData("単一FK - 任意 - ClientSetNull", DeleteBehavior.ClientSetNull, false, ConnectionStrings.SqlServer)]
// [InlineData("単一FK - 任意 - Cascade", DeleteBehavior.Cascade, false, ConnectionStrings.SqlServer)]
[Theory]
public void SingleForeignKeyTest(string title, DeleteBehavior deleteBehavior, bool isRequired)
{
    // 初期データ作成
    CreateSingleData(deleteBehavior, isRequired);
    
    Console.WriteLine($"{Environment.NewLine}================= {title} =================");
    
    using var context = CreateSingleContext(deleteBehavior, isRequired);

    // Userを取得
    var user = context.SingleFkUsers.Include(u => u.AuthoredBlogs).First();

    // 検証
    Validate(context, user);

    // 検証後の確認
    using var afterContext = CreateSingleContext(deleteBehavior, isRequired);
    PrintSingleBlogs("STEP4", afterContext);
}

なお、複数のInlineDataを有効にした場合、CREATE文は同じものができてしまいました。 (SetNullを渡していても、CASCADEになる等)

今回は検証ということもあり、今回はInlineDataを一つずつ削除していく形を取りました。詳しい方がいればお知らせを。。。

 

データ作成

1つのUserと、それを外部キー参照しているBlogを2つ作成します。  

private void CreateSingleData(DeleteBehavior deleteBehavior, bool isRequired)
{
    using var context = CreateSingleContext(deleteBehavior, isRequired);
    
    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();
        
    var user = new SingleFkUser { Name = "Foo"};
    context.SingleFkUsers.Add(user);

    var blog = new List<SingleFkBlog>
    {
        new SingleFkBlog
        {
            Content = "Single User Blog 1",
            Author = user,
            AuthorId = user.Id
        },
        new SingleFkBlog
        {
            Content = "Single User Blog 2",
            Author = user,
            AuthorId = user.Id
        }
    };

    context.SingleFkBlogs.AddRange(blog);
    context.SaveChanges();
}

 

テーブル作成
private SingleContext CreateSingleContext(DeleteBehavior deleteBehavior, bool isRequired)
{
    var opt = new DbContextOptionsBuilder<SingleContext>()
        .EnableSensitiveDataLogging()
        .UseSqlServer(@"Server=tcp:.;Database=efcore;User=sa;Password=Your_password123;")
        .Options;

    return new SingleContext(opt, deleteBehavior, isRequired);
}

 

検証

EF Coreの各処理ごとに、UserやBlogの内容を出力します。

private void Validate<T>(DbContext context, IUser<T> user)
{
    try {
        PrintEntities(context, "STEP-1", user);
        Console.WriteLine("==== SQL実行確認 ====");

        context.Remove(user);
        PrintEntities(context, "STEP-2", user);
        
        context.SaveChanges();
        PrintEntities(context, "STEP-3", user);
    }
    catch (Exception e)
    {
        Console.WriteLine("---- 例外発生 ----");
        Console.WriteLine(e.ToString());
    }
}

 

実行結果

dotnet test でテストコードを実行した時の結果を見ていきます。

SetNullの場合

CREATE TABLEの結果です。 ON DELETE SET NULL が設定されています。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [SingleFkUsers] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NULL,
          CONSTRAINT [PK_SingleFkUsers] PRIMARY KEY ([Id])
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [SingleFkBlogs] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          [AuthorId] int NULL,
          CONSTRAINT [PK_SingleFkBlogs] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_SingleFkBlogs_SingleFkUsers_AuthorId] FOREIGN KEY ([AuthorId]) 
              REFERENCES [SingleFkUsers] ([Id]) 
              ON DELETE SET NULL
      );

 
続いて、削除処理をした時のログです。

データ登録直後です。

STEP-1
    User:状態 'Unchanged'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) '1'

context.Remove(user);context.SaveChanges(); 後に

  • 2レコードの AuthorId に、 null をセット
  • 1レコードを削除

が実行されています。

また、Entityの状態も、変化しています。

STEP User AuthoredBlogs
1 Unchanged Unchanged
2 Deleted Modified
3 Detached Unchanged

ログはこちら。

STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Modified'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Modified'、Id '2'、外部キー(AuthorId) 'null'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [SingleFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [SingleFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (7ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [SingleFkUsers]
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) 'null'
STEP4
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.0 initialized 'SingleContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [s].[Id], [s].[AuthorId], [s].[Content]
      FROM [SingleFkBlogs] AS [s]
Id: '1' / AuthorId: 'null'
Id: '2' / AuthorId: 'null'

 

ClientSetNullの場合

テーブルは ON DELETE NO ACTION で設定されています。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [SingleFkBlogs] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          [AuthorId] int NULL,
          CONSTRAINT [PK_SingleFkBlogs] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_SingleFkBlogs_SingleFkUsers_AuthorId] FOREIGN KEY ([AuthorId]) 
              REFERENCES [SingleFkUsers] ([Id]) 
              ON DELETE NO ACTION
      );

 
ただ、NO ACTION にもかかわらず、SetNullと同じSQLが実行されています。

Entityの状態も同じです。

STEP User AuthoredBlogs
1 Unchanged Unchanged
2 Deleted Modified
3 Detached Unchanged

ログです。

STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Modified'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Modified'、Id '2'、外部キー(AuthorId) 'null'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [SingleFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [SingleFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (8ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [SingleFkUsers]
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) 'null'

 

Cascadeの場合

参考までに、Cascadeの場合のログです。

3つのDELETEが実行されています。また、Entityの状態も異なります。

STEP User AuthoredBlogs
1 Unchanged Unchanged
2 Deleted Deleted
3 Detached Detached

ログはこちら。

STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Deleted'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Deleted'、Id '2'、外部キー(AuthorId) '1'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [SingleFkBlogs]
      WHERE [Id] = @p0;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p0='2'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [SingleFkBlogs]
      WHERE [Id] = @p0;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (6ms) [Parameters=[@p1='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [SingleFkUsers]
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Detached'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Detached'、Id '2'、外部キー(AuthorId) '1'

 

multiple cascade pathsな関係を持つモデルでの違い

続いて、multiple cascade pathsな関係を持つモデルで試してみます。

 

multiple cascade pathsな関係のモデル

例えば、Blogが

  • Author -> Userを参照
  • Contributor -> Userを参照

と、2つの外部キーで1つのUserモデルを参照しているものです。

// User - Blog 間に、FKが2つの場合
// multiple cascade pathsな関係
public class MultiFkUser : IUser<MultiFkBlog>
{
    public int Id { get; set; }
    public string Name { get; set; }

    [InverseProperty("Author")]
    public List<MultiFkBlog> AuthoredBlogs { get; set; }
    
    [InverseProperty("Contributor")]
    public List<MultiFkBlog> ContributedToBlogs { get; set; }
    
}

public class MultiFkBlog: IBlog
{
    public int Id { get; set; }
    public string Content { get; set; }
    
    public int? AuthorId { get; set; }
    public MultiFkUser Author { get; set; }
    
    public int? ContributorId { get; set; }
    public MultiFkUser Contributor { get; set; }
}

今回、これらを使って確認してみます。

 

SQL Serverでの、SetNullやCascadeを確認

MS SQL Serverの場合、 multiple cascade paths な関係を持つモデルでは、ON DELETE SET NULLや ON DELETE CASCADE を設定できません。

テストコードで試したところ、CREATE TABLEの実行時に例外が発生しました。

 

SetNull
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
      Failed executing DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [MultiFkBlogs] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          [AuthorId] int NULL,
          [ContributorId] int NULL,
          CONSTRAINT [PK_MultiFkBlogs] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_AuthorId] FOREIGN KEY ([AuthorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE SET NULL,
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_ContributorId] FOREIGN KEY ([ContributorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE SET NULL
      );
  エラー メッセージ:
   Microsoft.Data.SqlClient.SqlException : Introducing FOREIGN KEY constraint 'FK_MultiFkBlogs_MultiFkUsers_ContributorId' on table 'MultiFkBlogs' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
Could not create constraint or index. See previous errors.

 

Cascade
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
      Failed executing DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [MultiFkBlogs] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          [AuthorId] int NULL,
          [ContributorId] int NULL,
          CONSTRAINT [PK_MultiFkBlogs] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_AuthorId] FOREIGN KEY ([AuthorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE CASCADE,
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_ContributorId] FOREIGN KEY ([ContributorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE CASCADE
      );
  エラー メッセージ:
   Microsoft.Data.SqlClient.SqlException : Introducing FOREIGN KEY constraint 'FK_MultiFkBlogs_MultiFkUsers_ContributorId' on table 'MultiFkBlogs' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
Could not create constraint or index. See previous errors.

 

SQLServerでの、ClientSetNull

一方、ClientSetNullであれば、例外は発生しません。

ただし、IncludeですべてのFKをたぐる場合と、一部のFKのみたぐる場合で結果が異なりました。

var user = withInclude
    // すべてのFKを含む
    ? context.MultiFkUsers
        .Include(u => u.AuthoredBlogs)
        .Include(u => u.ContributedToBlogs)
        .First()
    // 一部のFKだけ含む
    : context.MultiFkUsers
        .Include(u => u.AuthoredBlogs)
        .First();

 
そこで、

  1. AuthorとContributorが同じで、削除対象
  2. Authorだけ削除対象
  3. Contributorだけ削除対象
  4. Author、Contributorともに違う

の4種類のデータを用意し、それぞれがどうなるかを確認します。

var userTarget = new MultiFkUser {Name = "Target"};
var userFoo = new MultiFkUser {Name = "Foo"};
var userBar = new MultiFkUser {Name = "Bar"};
context.MultiFkUsers.AddRange(userTarget, userFoo, userBar);

var blogs = new List<MultiFkBlog>
{
    // AuthorとContributorが同じで、削除対象
    new MultiFkBlog
    {
        Content = "Same User Blog",
        Author = userTarget,
        AuthorId = userTarget.Id,
        Contributor = userTarget,
        ContributorId = userTarget.Id
    },
    // Authorだけ削除対象
    new MultiFkBlog
    {
        Content = "Only Author User Blog",
        Author = userTarget,
        AuthorId = userTarget.Id,
        Contributor = userFoo,
        ContributorId = userFoo.Id
    },
    // Contributorだけ削除対象
    new MultiFkBlog
    {
        Content = "Only Contributor User Blog",
        Author = userFoo,
        AuthorId = userFoo.Id,
        Contributor = userTarget,
        ContributorId = userTarget.Id
    },
    // Author、Contributorともに違う
    new MultiFkBlog
    {
        Content = "Another User Blog",
        Author = userFoo,
        AuthorId = userFoo.Id,
        Contributor = userBar,
        ContributorId = userBar.Id
    }
};

 

Include()で全部のFKを指定した場合

例外は発生せず、

Id: '1' / AuthorId: 'null' /ContributorId: 'null'
Id: '2' / AuthorId: 'null' /ContributorId: '2'
Id: '3' / AuthorId: '2' /ContributorId: 'null'
Id: '4' / AuthorId: '2' /ContributorId: '3'

と、必要な外部キーに NULL が設定されました。

# テーブルの定義
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [MultiFkBlogs] (
          [Id] int NOT NULL IDENTITY,
          [Content] nvarchar(max) NULL,
          [AuthorId] int NULL,
          [ContributorId] int NULL,
          CONSTRAINT [PK_MultiFkBlogs] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_AuthorId] FOREIGN KEY ([AuthorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE NO ACTION,
          CONSTRAINT [FK_MultiFkBlogs_MultiFkUsers_ContributorId] FOREIGN KEY ([ContributorId]) 
              REFERENCES [MultiFkUsers] ([Id]) 
              ON DELETE NO ACTION
      );

STEP-1
    User:状態 'Unchanged'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) '1'
==== SQL実行確認 ====
STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Modified'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Modified'、Id '2'、外部キー(AuthorId) 'null'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p2='1', @p0=NULL (DbType = Int32), @p1=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [MultiFkBlogs] SET [AuthorId] = @p0, [ContributorId] = @p1
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [MultiFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p1='3', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [MultiFkBlogs] SET [ContributorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (8ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [MultiFkUsers]
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) 'null'
STEP4
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.0 initialized 'MultiContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [m].[Id], [m].[AuthorId], [m].[Content], [m].[ContributorId]
      FROM [MultiFkBlogs] AS [m]

 

Include()で一部のFKのみ指定した場合

CREATE TABLEやデータ投入は成功したものの、最後のDELETE時に例外が発生しました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@p2='1', @p0=NULL (DbType = Int32), @p1=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [MultiFkBlogs] SET [AuthorId] = @p0, [ContributorId] = @p1
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [MultiFkBlogs] SET [AuthorId] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
      Failed executing DbCommand (11ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DELETE FROM [MultiFkUsers]
      WHERE [Id] = @p2;
      SELECT @@ROWCOUNT;
fail: Microsoft.EntityFrameworkCore.Update[10000]
      An exception occurred in the database while saving changes for context type 'MyApp.ClientSetNull.MultiContext'.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
       ---> Microsoft.Data.SqlClient.SqlException (0x80131904): The DELETE statement conflicted with the REFERENCE constraint "FK_MultiFkBlogs_MultiFkUsers_ContributorId". The conflict occurred in database "efcore", table "dbo.MultiFkBlogs", column 'ContributorId'.

 
エラーメッセージは

The DELETE statement conflicted with the REFERENCE constraint "FK_MultiFkBlogs_MultiFkUsers_ContributorId". The conflict occurred in database "efcore", table "dbo.MultiFkBlogs", column 'ContributorId'.

とあり、公式ドキュメントの

EF Core モデルに構成されている削除動作は、EF Core を使用してプリンシパル エンティティが削除され、依存エンティティがメモリ内に読み込まれている場合 (つまり、追跡されている依存エンティティの場合) にのみ適用されます。 対応する連鎖動作をデータベースに設定し、コンテキストによって追跡されていないデータに対して必要なアクションが適用されるようにする必要があります。 EF Core を使用してデータベースを作成すると、この連鎖動作が設定されます。

連鎖削除 - EF Core | Microsoft Docs

という挙動に合致しています。

 

PostgreSQLでの、SetNullやCascadeを確認

SQL Serverではmultiple cascade paths な関係を持つモデルを許可していませんが、データベースによっては許可しています。

そこで、PostgreSQLで試してみます。

SetNull

InlineData 属性で、データベースを切り替えるだけで試してみます。

// [InlineData("複数FK - SetNull - 任意 - Includeあり - MSSQLServer", 
//     DeleteBehavior.SetNull, false, true, DbEngine.MsSqlServer)]
[InlineData("複数FK - SetNull - 任意 - Includeあり - PostgreSQL", 
    DeleteBehavior.SetNull, false, true, DbEngine.PostgreSql)]

 
例外は発生せず、最後まで実行できました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (14ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "MultiFkBlogs" (
          "Id" integer NOT NULL GENERATED BY DEFAULT AS IDENTITY,
          "Content" text NULL,
          "AuthorId" integer NULL,
          "ContributorId" integer NULL,
          CONSTRAINT "PK_MultiFkBlogs" PRIMARY KEY ("Id"),
          CONSTRAINT "FK_MultiFkBlogs_MultiFkUsers_AuthorId" FOREIGN KEY ("AuthorId") 
              REFERENCES "MultiFkUsers" ("Id") 
              ON DELETE SET NULL,
          CONSTRAINT "FK_MultiFkBlogs_MultiFkUsers_ContributorId" FOREIGN KEY ("ContributorId") 
              REFERENCES "MultiFkUsers" ("Id") 
              ON DELETE SET NULL
      );

実行結果です。

STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Modified'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Modified'、Id '2'、外部キー(AuthorId) 'null'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (6ms) [Parameters=[@p2='1', @p0=NULL (DbType = Int32), @p1=NULL (DbType = Int32), @p4='2', @p3=NULL (DbType = Int32), @p6='3', @p5=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      UPDATE "MultiFkBlogs" SET "AuthorId" = @p0, "ContributorId" = @p1
      WHERE "Id" = @p2;
      UPDATE "MultiFkBlogs" SET "AuthorId" = @p3
      WHERE "Id" = @p4;
      UPDATE "MultiFkBlogs" SET "ContributorId" = @p5
      WHERE "Id" = @p6;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p7='1'], CommandType='Text', CommandTimeout='30']
      DELETE FROM "MultiFkUsers"
      WHERE "Id" = @p7;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Unchanged'、Id '1'、外部キー(AuthorId) 'null'
    AuthoredBlog:状態 'Unchanged'、Id '2'、外部キー(AuthorId) 'null'
STEP4
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.0 initialized 'MultiContext' using provider 'Npgsql.EntityFrameworkCore.PostgreSQL' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT m."Id", m."AuthorId", m."Content", m."ContributorId"
      FROM "MultiFkBlogs" AS m
Id: '4' / AuthorId: '2' /ContributorId: '3'
Id: '1' / AuthorId: 'null' /ContributorId: 'null'
Id: '2' / AuthorId: 'null' /ContributorId: '2'
Id: '3' / AuthorId: '2' /ContributorId: 'null'

 

Cascade

こちらもCREATE TABLEが成功しています。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (12ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "MultiFkBlogs" (
          "Id" integer NOT NULL GENERATED BY DEFAULT AS IDENTITY,
          "Content" text NULL,
          "AuthorId" integer NULL,
          "ContributorId" integer NULL,
          CONSTRAINT "PK_MultiFkBlogs" PRIMARY KEY ("Id"),
          CONSTRAINT "FK_MultiFkBlogs_MultiFkUsers_AuthorId" FOREIGN KEY ("AuthorId") 
              REFERENCES "MultiFkUsers" ("Id") 
              ON DELETE CASCADE,
          CONSTRAINT "FK_MultiFkBlogs_MultiFkUsers_ContributorId" FOREIGN KEY ("ContributorId") 
              REFERENCES "MultiFkUsers" ("Id") 
              ON DELETE CASCADE
      );

実行結果です。

multiple cascade paths な関係を持つモデルでもCascade Deleteができています。

STEP-2
    User:状態 'Deleted'、参照Author件数 '2'
    AuthoredBlog:状態 'Deleted'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Deleted'、Id '2'、外部キー(AuthorId) '1'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@p0='1', @p1='2'], CommandType='Text', CommandTimeout='30']
      DELETE FROM "MultiFkBlogs"
      WHERE "Id" = @p0;
      DELETE FROM "MultiFkBlogs"
      WHERE "Id" = @p1;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
      DELETE FROM "MultiFkUsers"
      WHERE "Id" = @p2;
STEP-3
    User:状態 'Detached'、参照Author件数 '2'
    AuthoredBlog:状態 'Detached'、Id '1'、外部キー(AuthorId) '1'
    AuthoredBlog:状態 'Detached'、Id '2'、外部キー(AuthorId) '1'
STEP4
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.0 initialized 'MultiContext' using provider 'Npgsql.EntityFrameworkCore.PostgreSQL' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT m."Id", m."AuthorId", m."Content", m."ContributorId"
      FROM "MultiFkBlogs" AS m
Id: '4' / AuthorId: '2' /ContributorId: '3'

 

まとめ (再掲)

  • ClientSetNullは ON DELETE NO ACTION だが、Nullが設定される
  • SQL Serverの場合、外部キーの状況によってはSetNullではなくClientSetNullを使う
    • multiple cascade pathsな関係を持つモデルなど
  • データベースによっては、multiple cascade pathsな関係を持つモデルも利用可能
  • On Deleteを動作させるには、関係するモデルをInclude()で読み込んでおく

 

その他参考

本題とは関係ない内容ですが、参考になったものをメモしておきます。

 

Null条件演算子やNull合体演算子

項目がnullだったら null という文字列を出力したい時、Null条件演算子(?.)やNull合体演算子(??)を使うことで簡潔に書けました。

Console.WriteLine($"Id: '{blog.Id}' / AuthorId: '{blog.AuthorId?.ToString() ?? "null"}'");

 

switch式

データベースエンジンによってオプション設定を変えたい時、switch式を使うことで簡潔に書けました。
C# 8.0 switch 式 | ++C++; // 未確認飛行 C ブログ

var opt = dbEngine switch
            {
                DbEngine.MsSqlServer => new DbContextOptionsBuilder<MultiContext>()
                    .EnableSensitiveDataLogging()
                    .UseSqlServer(@"Server=tcp:.;Database=efcore;User=sa;Password=Your_password123;")
                    .Options,
                DbEngine.PostgreSql => new DbContextOptionsBuilder<MultiContext>()
                    .EnableSensitiveDataLogging()
                    .UseNpgsql(@"Host=localhost;Database=efcore;Port=55432;Username=postgres;Password=Your_password123")
                    .Options,
                _ => throw new Exception()
                
            };
SQLのログ出力について

以下のstackoverflowに従うと実装できそうな気もしますが、試せていません。。。 Get SQL code from an Entity Framework Core IQueryable - Stack Overflow

 

ソースコード

GitHubに上げました。MyApp/ClientSetNullMyApp/ClientSetNullTest あたりです。
https://github.com/thinkAmi-sandbox/EntityFramework_Core_Sample

2019年の振り返りと2020年の目標

例年通り、2019年の振り返りと2020年の目標っぽいものを書いてみます。

 

2019年の振り返り

2018年の振り返りと2019年の目標 - メモ的な思考的な で立てた目標を振り返ってみます。

 

Pythonをベースに、いろいろな分野の素振り

仕事でPython以外の言語にもふれたこともあり、

  • Laravel
  • AWS AmplifyやCDK
  • .NET Core

などの素振りをすることが多かったです。

 

筋トレの継続

残念ながら、時間的余裕がなくなったタイミングで途切れ、そのままになってしまいました。

効果は感じていただけに残念。。。

 

何らかのテストを受験

今年は

を受験し、合格できました。  
 

小さなことでも良いので、2018年よりアウトプットを増やす

Blogでのアウトプットが微減してしまい、目標を達成できませんでした。。。

 

その他

イベントでの発表

Python関連の大きなイベントにそれぞれCfPが採用され、以下の2つのイベントで発表しました。ありがたい限りです。

 
両方とも社内のサポート体制にも助けられ、無事に発表ができました。

 

GitHub

2018年よりは増えているものの。。。

f:id:thinkAmi:20200101010627p:plain:w400

 

イベント

2019年はこんな感じでした。

  1. #stapy #glnagano みんなのPython勉強会 in 長野#3に参加しました - メモ的な思考的な
  2. DjangoCongress JP 2019 に参加 & 発表しました #djangocongress - メモ的な思考的な
  3. #gdg信州 #io19jp Google I/O Extended 2019 in Shinshuに参加しました - メモ的な思考的な
  4. #awsxon #jawsug AWS Expert Online at JAWS-UG長野 (AppSync編) に参加しました - メモ的な思考的な
  5. #agileshinano #glnagano Agile Jam in ながのに参加しました - メモ的な思考的な
  6. #pyconjp PyCon JP 2019に参加しました & 発表しました - メモ的な思考的な

 
また、記録には残せていませんが、ギークラボで開催されるイベントや、技術書典6,7にも参加していました。

 

時の流れ

2019年も時の流れに抗えず、IT系のイベントよりも優先した感じになりました。

2020年の後半から、ペースを取り戻したいところ。。。

 

2020年の目標っぽいもの

引き続き2020年前半は時の流れがあるので、自分の中で完結できる

  • 色々な分野の素振り
  • レーニングの復活
  • 引き続き、何らかの試験を受ける

を目標っぽいものにします。

特に、トレーニングの復活は何とかしたいです。

 
というところで、今年もよろしくお願いします。

Handsontable 7.3.0で、オブジェクト配列を使ったloadData()が動作しない

グリッドライブラリの Handsontable では、 loadData() を使ってグリッドのデータを更新できます。
https://handsontable.com/docs/7.3.0/Core.html#loadData

 
ただ、手元で試したたところ動作しないことがあったため、メモを残しておきます。

 
目次

 

環境

  • Handsontable 7.3.0 / 8.0.0-beta.1

 

現象

以下のようなコードを実装しても、Handsontableのグリッドにデータが表示されませんでした。

// オブジェクト配列を用意
const objectData = [
    {name: 'りんご', price: 100},
    {name: 'みかん', price: 200}
]

// Handsontableを生成
const element = document.getElementById(divId);
const table = new Handsontable(element, {
    licenseKey: 'your license'
});

// loadDataでロード
table.loadData(objectData);

 
他にグリッドにデータを投入する方法としては、

  • loadData()に、二次元配列を渡す
  • Handsontableオブジェクト生成時に、オブジェクト配列を渡す

があったため、

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!-- v7.3.0の場合は動作しない-->
    <script src="https://cdn.jsdelivr.net/npm/handsontable@7.3.0/dist/handsontable.full.min.js">
    </script>
    <link type="text/css"
          rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/handsontable@7.3.0/dist/handsontable.full.min.css">
</head>
<body>
    <h3>二次元配列版</h3>
    <div id="array"></div>
    <hr />

    <h3>オブジェクト配列版</h3>
    <div id="object"></div>
    <hr />

    <h3>オブジェクト生成時にオブジェクト配列を渡す版</h3>
    <div id="creation"></div>

    <script>
        document.addEventListener("DOMContentLoaded", () => {

            // 二次元配列データをloadData
            const arrayData = [
                ['りんご', 100],
                ['みかん', 200]
            ]
            load('array', arrayData);

            // オブジェクトの配列データをloadData
            const objectData = [
                {name: 'りんご', price: 100},
                {name: 'みかん', price: 200}
            ]
            load('object', objectData);

            // オブジェクトの配列データをオブジェクト生成時に渡す
            const element = document.getElementById('creation');
            const ht = new Handsontable(element, {
                data: objectData,
                licenseKey: 'your license'
            });
        });

        function load(divId, data) {
            const element = document.getElementById(divId);
            const table = new Handsontable(element, {
                licenseKey: 'your license'
            });
            table.loadData(data);
        }
    </script>
</body>
</html>

としたところ、オブジェクト配列をloadData()に渡した時のみ、表示されませんでした。

f:id:thinkAmi:20191223221041p:plain:w400

 

原因と対応

まさにそのものがissueにありました。
Using loadData on an object data doesn't work · Issue #4204 · handsontable/handsontable

 
現時点の最新版(7.3.0)で対応できる内容としては、上記

  • loadData()に、二次元配列を渡す
  • Handsontableオブジェクト生成時に、オブジェクト配列を渡す

のどちらかで実装する必要がありそうです。

 
なお、issueには、8.0.0-beta.1では修正されているとのコメントがあったため、

<!-- v8.0.0の場合は動作する -->
<script src="https://cdn.jsdelivr.net/gh/handsontable/handsontable@release/8.0.0-beta.1/dist/handsontable.full.js">
</script>
<!-- v7.3.0の場合は動作しない-->
<!--
<script src="https://cdn.jsdelivr.net/npm/handsontable@7.3.0/dist/handsontable.full.min.js">
</script>
-->

とバージョンを差し替えてみたところ、いずれも表示されました。

 
f:id:thinkAmi:20191223221424p:plain:w400

 

ソースコード

GitHubに上げました。ディレクトloadData_sample が今回のファイルです。
https://github.com/thinkAmi-sandbox/Handsontable-sample

 
なお、Handsontableは7系からライセンスが変わったようです。
Handsontable 7.0.0を使う人へ - Qiita

EntityFramework Core 3.1 にて、null許容参照型機能の有効・無効ごとに、NOT NULL制約の状況を確認してみた

EntityFramework Core (以降、EF Core)のドキュメントを見ていたところ、C#8.0以降のnull許容参照型機能により、NOT NULL制約の付き方が変わるようでした。
必須およびオプションのプロパティ-EF Core | Microsoft Docs

そこで今回、null許容参照型機能の有効・無効ごとに、どんな感じになるのかを試してみました。

 

目次

 

環境

前回の記事の環境を、今回も引き続き使います。

 

null許容参照型に対する設定について

null許容参照型機能の有効化/無効化は、次のうちのどちらかにより可能です。

  • プロジェクトファイル (*.csproj)
    • プロジェクト全体の設定を変更
  • nullable ディレクティブ
    • ディレクティブがある箇所の設定を変更

 

プロジェクトファイル (*.csproj) での設定

以下のドキュメントに記載があります。
null 許容参照型を使用して設計する | Microsoft Docs

csprojファイルの中にある

  • LangVersion
  • Nullable

を、以下のように指定することで、null許容参照型機能が有効になります。

<PropertyGroup>
  <TargetFramework>netcoreapp3.1</TargetFramework>
  <LangVersion>8</LangVersion>
  <Nullable>enable</Nullable>
</PropertyGroup>

 
ちなみに、Rider IDEを使っている場合には、csprojファイルのプロパティから変更できます。

csprojファイルを右クリックし、コンテキストメニューから Properties... を選択します。

f:id:thinkAmi:20191219231313p:plain:w400

 
ApplicationのLanguageの中に、設定箇所があります。

f:id:thinkAmi:20191219231358p:plain:w400

 

nullableディレクティブの場合

null許容参照型機能を一部だけ設定したい場合は、nullableディレクティブを使うことになります。
null 許容参照型 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

つまり

#nullable enable
    public class RqBlog4
    {
        public int Id { get; set; }
        public string? Url { get; set; }
    }
    
    public class RqBlog5
    {
        public int Id { get; set; }
        public string Url { get; set; }
    }
#nullable restore

と書くと、RqBlog4・RqBlog5の中だけ、null許容参照型機能が有効になります。

 

ここまでで設定方法を見てきました。

次はnull許容参照型機能の有効/無効により、どのように動作が異なるかを確認してみます。

 

用意したモデルコード

以下の5種類を用意しました。

  • RqBlog1クラス
    • null許容参照型を使っていない場合
  • RqBlog2クラス
    • null許容参照型を使っていないが、 [Require] Data Annotationを使っている場合
  • RqBlog3クラス、 RqPost3クラス
    • null許容参照型を外部キーに使っていない場合
  • RqBlog4クラス
    • null許容参照型を使っている & #nullable enable を使っている場合
  • RqBlog5クラス
    • null許容参照型を使っていない & #nullable enable を使っている場合

 
実際のソースコードはこんな感じです。

フィールド RqBlog*.Url 、もしくは外部キーの RqPost3.RqBlogFk がどのように変化するかを見ます。

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace EFCoreRelationSample.Models
{
    public class RqBlog1
    {
        public int Id { get; set; }
        public string Url { get; set; }
    }

    public class RqBlog2
    {
        public int Id { get; set; }
        
        [Required]
        public string Url { get; set; }
    }
    
    public class RqBlog3
    {
        [Key]
        public string Url { get; set; }
        
        // コレクションナビゲーションプロパティ
        public List<RqPost3> Posts { get; set; }
    }

    public class RqPost3
    {
        public int Id { get; set; }
        public string Content { get; set; }

        // 外部キー
        [ForeignKey("RqBlog3Url")]
        public string RqBlogFk { get; set; }
        
        // 参照ナビゲーションプロパティ
        public RqBlog3 Blog { get; set; }
    }

#nullable enable
    public class RqBlog4
    {
        public int Id { get; set; }
        public string? Url { get; set; }
    }
    
    public class RqBlog5
    {
        public int Id { get; set; }
        public string Url { get; set; }
    }
#nullable restore
    
}

 

null許容参照型機能を無効にしたままの場合(デフォルト)

デフォルトのままで、マイグレーションを作成・適用した時のSQLを見てみます。

f:id:thinkAmi:20191219231508p:plain:w400

 

null許容参照型を使っていない場合

Urlには NOT NULL制約がありません。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog1List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog1List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

null許容参照型を使っていないが、 [Require] Data Annotationを使っている場合

Urlに、NOT NULL制約が付きました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog2List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog2List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

null許容参照型を外部キーに使っていない場合

外部キーである RqBlogFk には、NOT NULL制約がありません。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog3List" (
          "Url" TEXT NOT NULL CONSTRAINT "PK_RqBlog3List" PRIMARY KEY
      );

# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqPost3List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqPost3List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "RqBlogFk" TEXT NULL,
          "BlogUrl" TEXT NULL,
          CONSTRAINT "FK_RqPost3List_RqBlog3List_BlogUrl" FOREIGN KEY ("BlogUrl") REFERENCES "RqBlog3List" ("Url") ON DELETE RESTRICT
      );

 

null許容参照型を使っている & #nullable enable を使っている場合

Urlには NOT NULL制約がありません。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog4List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog4List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

null許容参照型を使っていない & #nullable enable を使っている場合

nullableディレクティブの影響により、null許可参照型機能が有効となり、UrlにNOT NULL制約が付きました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog5List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog5List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

null許容参照型機能を有効にした場合

csprojファイルを編集し、null許容参照型機能を有効にしたあと、マイグレーションを作成・実行してみます。

f:id:thinkAmi:20191219231529p:plain:w400  

null許容参照型を使っていない場合

先ほどとは異なり、UrlにNOT NULL制約が付きました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog1List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog1List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

null許容参照型を使っていないが、 [Require] Data Annotationを使っている場合

こちらは変わらず、UrlはNOT NULL制約が付いたままでした。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog2List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog2List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

null許容参照型を外部キーに使っていない場合

先ほどとは異なり、外部キー BlogUrl にNOT NULL制約が付きました。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog3List" (
          "Url" TEXT NOT NULL CONSTRAINT "PK_RqBlog3List" PRIMARY KEY
      );

# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqPost3List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqPost3List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NOT NULL,
          "RqBlogFk" TEXT NOT NULL,
          "BlogUrl" TEXT NOT NULL,
          CONSTRAINT "FK_RqPost3List_RqBlog3List_BlogUrl" FOREIGN KEY ("BlogUrl") REFERENCES "RqBlog3List" ("Url") ON DELETE CASCADE
      );

 

null許容参照型を使っている & #nullable enable を使っている場合

先程と同じく、UrlにはNOT NULL制約はありませんでした。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog4List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog4List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

null許容参照型を使っていない & #nullable enable を使っている場合

先程と同じく、UrlにはNOT NULL制約が付いていました。

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "RqBlog5List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_RqBlog5List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

以上より、null許容参照型機能を無効から有効に切り替えた場合は、実装方法によってはNULL許可からNOT NULL制約付与へと、意味が逆になるということが分かりました。

そのため、EF CoreにおけるNull 許容の参照型の使用については、

null 許容の参照型に対する EF Core のサポートについて説明し、それらを操作するためのベストプラクティスについて説明します。

Null 許容の参照型の使用-EF Core | Microsoft Docs

と書かれているページを参照すると良さそうです。

 

ソースコード

GitHubに上げました。 EFCoreRelationSample/Models/NullableFunction.cs が今回のファイルです。
https://github.com/thinkAmi-sandbox/ASP_Net_Core-sample

EntityFramework Core 3.1 を使って、色々な一対多のリレーションを試してみた

前回、EntityFramework Core (以下EF Core)を使って、一対多のリレーションがあるデータを作成してみました。
ASP .NET Core 3.1 + EntityFramework Core を使って、一対多のリレーションがあるデータをレスポンスしてみた - メモ的な思考的な

 
そんな中、公式ドキュメントを読んでいると、いろんなパターンの一対多のリレーションを定義する方法が記載されていました。
リレーションシップ-EF Core | Microsoft Docs

ただ、EF CoreからどんなSQLDDL文が発行されるのか分からなかったことから、今回試してみることにしました。

 

目次

 

環境

前回の記事の環境を、今回も引き続き使います。

 

規約による定義(単一ナビゲーションプロパティ)

親クラスに単一ナビゲーションプロパティを定義するだけで、リレーションができるのは楽です。

ただ、裏側ではどんなDDLが発行されるのか気になったため、試してみます。

 

前回同様、Blog <-> Postでリレーションを作成するためのモデル

// Blog
public class BlogOfSingleNavProp
{
    public int Id { get; set; }
    public string Url { get; set; }
    
    // 単一ナビゲーションプロパティ(Single Navigation Property)のみ定義
    public List<PostOfSingleNavProp> PostsOfFk { get; set; }
}

// Post
public class PostOfSingleNavProp
{
    public int Id { get; set; }
    public string Content { get; set; }
}

を用意します。

次に、マイグレーションを作成・DBへ適用します。

# マイグレーションの作成
$ dotnet ef migrations add AddSingleNavigationProperty

# DBへの適用
$ dotnet ef database update

 
dotnet ef database update 時に発行されるDDLがログに出力されるため、内容を確認します。

Postにフィールド BlogOfSingleNavPropId が自動生成され、Blogへの外部キーが設定されました。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "BlogOfSingleNavProps" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_BlogOfSingleNavProps" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );
      
# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "PostOfSingleNavProps" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_PostOfSingleNavProps" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogOfSingleNavPropId" INTEGER NULL,   # 自動生成された列
          CONSTRAINT "FK_PostOfSingleNavProps_BlogOfSingleNavProps_BlogOfSingleNavPropId" 
              FOREIGN KEY ("BlogOfSingleNavPropId") 
              REFERENCES "BlogOfSingleNavProps" ("Id") 
              ON DELETE RESTRICT
      );
      
# 自動生成されたPostのIndex
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_PostOfSingleNavProps_BlogOfSingleNavPropId" 
          ON "PostOfSingleNavProps" ("BlogOfSingleNavPropId");

 
スペースの関係上、以降のパターンは

のみを記載します。

 

Data Annotation によるリレーションの定義

Data Annotationによるリレーションは、2つの方法が用意されています。

  • [ForeignKey]
  • [InverseProperty]

ともに、データアノテーション名前空間 System.ComponentModel.DataAnnotations.Schema で定義されています。
https://docs.microsoft.com/ja-jp/dotnet/api/system.componentmodel.dataannotations.schema?view=netcore-3.1

今回はそれぞれ試してみます。

 

[ForeignKey] によるリレーション定義

[ForeignKey] の用途としては、主に外部キーの名前がEF Coreの命名規則と異なる場合に使うようです。

また、 [ForeignKey] は、以下のいずれかのパターンで付与します。

  • 依存エンティティ(子)の外部キーに付与
  • 依存エンティティ(子)の参照ナビゲーションプロパティに付与
  • プリンシパルエンティティ(親)の参照ナビゲーションプロパティに付与

なお、いずれも発行されるDDLは同じになります。

 

依存エンティティの外部キーに付与
public class Blog1
{
    public int Id { get; set; }
    public string Url { get; set; }
    public List<Post1> Posts { get; set; }
}

public class Post1
{
    public int Id { get; set; }
    public string Content { get; set; }

    // 依存エンティティの外部キーに付与
    [ForeignKey("Blog")]
    public int BlogFk { get; set; }
    
    // 参照ナビゲーションプロパティ
    public Blog1 Blog { get; set; }
}

 
結果です。 BlogFk の参照先が Blog.Id になっています。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog1List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog1List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

# Post      
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post1List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post1List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogFk" INTEGER NOT NULL,
          CONSTRAINT "FK_Post1List_Blog1List_BlogFk" 
              FOREIGN KEY ("BlogFk") 
              REFERENCES "Blog1List" ("Id") 
              ON DELETE CASCADE
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post1List_BlogFk" ON "Post1List" ("BlogFk");

 

依存エンティティの参照ナビゲーションプロパティに付与
public class Blog2
{
    public int Id { get; set; }
    public string Url { get; set; }
    public List<Post2> Posts { get; set; }
}

public class Post2
{
    public int Id { get; set; }
    public string Content { get; set; }

    // 外部キー
    public int BlogFk { get; set; }

    // 参照ナビゲーションプロパティに付与
    [ForeignKey("BlogFk")]
    public Blog2 Blog { get; set; }
}

 
こちらも同じ結果となりました。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog2List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog2List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

# Post      
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post2List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post2List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogFk" INTEGER NOT NULL,
          CONSTRAINT "FK_Post2List_Blog2List_BlogFk" 
              FOREIGN KEY ("BlogFk") 
              REFERENCES "Blog2List" ("Id") 
              ON DELETE CASCADE
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post2List_BlogFk" ON "Post2List" ("BlogFk");

 

プリンシパルエンティティの参照ナビゲーションプロパティに付与
public class Blog3
{
    public int Id { get; set; }
    public string Url { get; set; }
    
    // コレクションナビゲーションプロパティ
    [ForeignKey("BlogFk")]
    public List<Post3> Posts { get; set; }
}

public class Post3
{
    public int Id { get; set; }
    public string Content { get; set; }

    public int BlogFk { get; set; }
    public Blog3 Blog { get; set; }
}

 

こちらも同じ結果となりました。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog3List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog3List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL

# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post3List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post3List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogFk" INTEGER NOT NULL,
          CONSTRAINT "FK_Post3List_Blog3List_BlogFk" 
              FOREIGN KEY ("BlogFk") 
              REFERENCES "Blog3List" ("Id") 
              ON DELETE CASCADE
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post3List_BlogFk" ON "Post3List" ("BlogFk");

 

[InverseProperty] によるリレーション定義

[InverseProperty] の用途としては、一つの親クラスに対して、子クラスから複数の外部キーを定義する場合になります。

以下は、子の

  • Author
  • Contributor

という2つの項目が、それぞれ別の外部キー制約として定義する例です。

public class Post4
{
    public int Id { get; set; }
    public string Content { get; set; }

    public int AuthorId { get; set; }
    public User4 Author { get; set; }

    public int ContributorId { get; set; }
    public User4 Contributor { get; set; }
}

public class User4
{
    public int Id { get; set; }
    public string Name { get; set; }

    // Author向けのリレーション
    [InverseProperty("Author")]
    public List<Post4> AuthoredPosts { get; set; }
    
    // Contributor向けのリレーション
    [InverseProperty("Contributor")]
    public List<Post4> ContributedToPosts { get; set; }
}

 
結果です。

Postの

  • AuthorId
  • ContributorId

に対し、それぞれUserのIdが外部キー制約として設定されました。

# User
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "User4List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_User4List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL
      );
      
# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post4List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post4List" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "AuthorId" INTEGER NOT NULL,
          "ContributorId" INTEGER NOT NULL,
          CONSTRAINT "FK_Post4List_User4List_AuthorId" 
              FOREIGN KEY ("AuthorId") 
              REFERENCES "User4List" ("Id") ON DELETE CASCADE,
          CONSTRAINT "FK_Post4List_User4List_ContributorId" 
              FOREIGN KEY ("ContributorId") 
              REFERENCES "User4List" ("Id") ON DELETE CASCADE
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post4List_AuthorId" ON "Post4List" ("AuthorId");
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post4List_ContributorId" ON "Post4List" ("ContributorId");

 

外部キーで主キー以外のプロパティを参照する

主キーとは別のフィールドを、外部キーの参照先として設定したい場合です。

この時は、Fluent APIを使い、プリンシパルキープロパティを構成します。

プリンシパルキープロパティは、単一・複数あるため、それぞれ試してみます。

 

単一プリンシパルキーの構成

以下の例は、

  • 主キー:Blog5.Id
  • 外部キー:Post5.BlogUrl
  • 外部キーの参照先:Blog5.Url

として設定したい場合のモデルです。

public class Blog5
{
    public int Id { get; set; }      // 主キー
    public string Url { get; set; }  // 外部キーの参照先
    public string Description { get; set; }

    public List<Post5> Posts { get; set; }
}

public class Post5
{
    public int Id { get; set; }
    public string Title { get; set; }

    public string BlogUrl { get; set; }  // 外部キー
    public Blog5 Blog { get; set; }
}

 

Fluent APIは、DbContextの OnModelCreating() をオーバーライドして実装します。

modelBuilder.Entity<Post5>()
    .HasOne(m => m.Blog)  // 一側
    .WithMany(m => m.Posts)  // 多側
    .HasForeignKey(m => m.BlogUrl)  // 外部キーのフィールド
    .HasPrincipalKey(m => m.Url);   // 外部キーから参照する親のフィールド

 
結果です。

外部キーから参照する親のフィールド(Url)に対し、UNIQUE制約が追加されています。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog5List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog5List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL,
          "Description" TEXT NULL,
          CONSTRAINT "AK_Blog5List_Url"  // 制約が追加
              UNIQUE ("Url")
      );
      
# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post5List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post5List" PRIMARY KEY AUTOINCREMENT,
          "Title" TEXT NULL,
          "BlogUrl" TEXT NULL,
          CONSTRAINT "FK_Post5List_Blog5List_BlogUrl" 
              FOREIGN KEY ("BlogUrl") 
              REFERENCES "Blog5List" ("Url")  // 外部キーは親のUrl
              ON DELETE RESTRICT
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_Post5List_BlogUrl" ON "Post5List" ("BlogUrl");

 

複数プリンシパルキーの構成

複合プリンシパルキーは、複合外部キーとして親のフィールドを参照したい時に使います。

以下の例は、

  • 主キー:Blog6.Id
  • 複合外部キー
    • Post6.BlogUrlとPost6.Author
  • 複合外部キーの参照先
    • Post6.BlogUrl -> Blog6.Url
    • Post6.BlogAuthor -> Blog6.Author

として設定したい場合のモデルです。

public class Blog6
{
    public int Id { get; set; }         // 主キー
    public string Url { get; set; }     // BlogUrlの参照先
    public string Author { get; set; }  // BlogAuthorの参照先
    public string Description { get; set; }

    public List<Post6> Posts { get; set; }
}

public class Post6
{
    public int Id { get; set; }
    public string Title { get; set; }

    public string BlogUrl { get; set; }     // 複合外部キー
    public string BlogAuthor { get; set; }  // 複合外部キー
    public Blog6 Blog { get; set; }
}

 
単一の場合と同様、Fluent APIにて構成します。

modelBuilder.Entity<Post6>()
    .HasOne(m => m.Blog)
    .WithMany(m => m.Posts)
    .HasForeignKey(m => new {m.BlogUrl, m.BlogAuthor})
    .HasPrincipalKey(m => new {m.Url, m.Author});

 
結果です。

Blogには UNIQUE ("Url", "Author") という制約が追加されています。

Postにも、 FOREIGN KEY ("BlogUrl", "BlogAuthor") という外部キー制約が追加されています。

また、複合外部キーの場合、Indexの追加はありませんでした。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog6List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog6List" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL,
          "Author" TEXT NOT NULL,
          "Description" TEXT NULL,
          CONSTRAINT "AK_Blog6List_Url_Author" 
              UNIQUE ("Url", "Author")
          
# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Post6List" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Post6List" PRIMARY KEY AUTOINCREMENT,
          "Title" TEXT NULL,
          "BlogUrl" TEXT NULL,
          "BlogAuthor" TEXT NULL,
          CONSTRAINT "FK_Post6List_Blog6List_BlogUrl_BlogAuthor" 
              FOREIGN KEY ("BlogUrl", "BlogAuthor") 
              REFERENCES "Blog6List" ("Url", "Author") 
              ON DELETE RESTRICT
      );
      
# Index:なし

 

ナチュラルキーの主キーに対する、外部キー制約

EF Coreでは、主キーをサロゲートキーではなくナチュラルキーでも定義できます。

そこで、ナチュラルキーの主キーに対する外部キー制約も試してみます。

 

単一外部キーの場合

以下のモデルは

  • 主キーは、Blog・Postともにナチュラルキー
    • Blogは Url
    • Postは Title
  • Postの外部キー( UrlFk )は、Blogの主キー(ナチュラルキー)である Url を参照している

という定義です。

public class NkBlog1
{
    [Key]
    public string Url { get; set; }

    public List<NkPost1> Posts { get; set; }
}

public class NkPost1
{
    [Key]
    public string Title { get; set; }
    public string Content { get; set; }

    [ForeignKey("Blog")]
    public string UrlFk { get; set; }
    public NkBlog1 Blog { get; set; }
}

 
このモデルをマイグレーションすると、以下のDDLが発行されました。

  • Blogの Url が主キーになる
  • Postの UrlFk がBlogの Url を参照した外部キーになる

ということが分かります。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NkBlog1List" (
          "Url" TEXT NOT NULL CONSTRAINT "PK_NkBlog1List" PRIMARY KEY
      );

# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NkPost1List" (
          "Title" TEXT NOT NULL CONSTRAINT "PK_NkPost1List" PRIMARY KEY,
          "Content" TEXT NULL,
          "UrlFk" TEXT NULL,
          CONSTRAINT "FK_NkPost1List_NkBlog1List_UrlFk" 
              FOREIGN KEY ("UrlFk") 
              REFERENCES "NkBlog1List" ("Url") 
              ON DELETE RESTRICT
      );
      
# Index
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX "IX_NkPost1List_UrlFk" ON "NkPost1List" ("UrlFk");

 

複合外部キーの場合

以下のモデルを使って

  • 主キーは、Blog・Postともにナチュラルキー
    • Blogは UrlAuthor
    • Postは UrlAuthorTitle
  • Postの外部キーは2つ(UrlとAuthor)で、Blogの主キー(ナチュラルキー)であるUrlとAuthorを参照している

を定義したいケースです。

public class NkBlog2
{
    public string Url { get; set; }     // 複合主キー(ナチュラルキー)
    public string Author { get; set; }  // 複合主キー(ナチュラルキー)

    public string Description { get; set; }
    
    public List<NkPost2> Posts { get; set; }

}

public class NkPost2
{
    public string Url { get; set; }     // 複合主キー(ナチュラルキー & 外部キー)
    public string Author { get; set; }  // 複合主キー(ナチュラルキー & 外部キー)
    public string Title { get; set; }   // 複合主キー(ナチュラルキー、これだけ外部キー参照なし)

    public string Content { get; set; }
    
    public NkBlog2 Blog { get; set; }
}

 
この場合、Fluent APIを使ってDbContextに定義します。

まずは、Blogの複合主キー(ナチュラルキー)の定義です。

// Blogの複合主キー(ナチュラルキー)
modelBuilder.Entity<NkBlog2>()
    .HasKey(m => new {m.Url, m.Author});

 
続いて、Blogの複合主キーを参照する、Postの複合外部キーの定義です。

// Postの複合外部キー
modelBuilder.Entity<NkPost2>()
    .HasOne(p => p.Blog)
    .WithMany(m => m.Posts)
    .HasForeignKey(m => new {m.Url, m.Author});

 
最後に、Postの複合主キーを定義します。

// Postの複合主キー
modelBuilder.Entity<NkPost2>()
    .HasKey(m => new {m.Url, m.Author, m.Title});

 
結果です。

Blogは CONSTRAINT "PK_NkBlog2List" PRIMARY KEY ("Url", "Author") と、主キーが複合ナチュラルキーになりました。

# Blog
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NkBlog2List" (
          "Url" TEXT NOT NULL,
          "Author" TEXT NOT NULL,
          "Description" TEXT NULL,
          CONSTRAINT "PK_NkBlog2List" PRIMARY KEY ("Url", "Author")
      );

 
Postは

  • 複合主キー: PRIMARY KEY ("Url", "Author", "Title"),
  • 複合外部キー: FOREIGN KEY ("Url", "Author")

と定義されました。

# Post
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "NkPost2List" (
          "Url" TEXT NOT NULL,
          "Author" TEXT NOT NULL,
          "Title" TEXT NOT NULL,
          "Content" TEXT NULL,
          CONSTRAINT "PK_NkPost2List" 
              PRIMARY KEY ("Url", "Author", "Title"),
          CONSTRAINT "FK_NkPost2List_NkBlog2List_Url_Author"
              FOREIGN KEY ("Url", "Author") 
              REFERENCES "NkBlog2List" ("Url", "Author") 
              ON DELETE CASCADE
      );

なお、Indexの定義はありませんでした。

 

ソースコード

GitHubに上げました。

  • Models/BlogPostOfFkDataAnnotation.cs
  • Models/BlogPostOfInversePropDataAnnotation.cs
  • Models/BlogPostOfPrincipal.cs
  • Models/BlogPostOfSingleNavProp.cs
  • Models/NkBlogPostOfFk.cs
  • Models/NkBlogPostOfMultiFk.cs
  • Models/MyContext.cs

あたりが今回のファイルです。
https://github.com/thinkAmi-sandbox/ASP_Net_Core-sample

ASP .NET Core 3.1 + EntityFramework Core を使って、一対多のリレーションがあるデータをレスポンスしてみた

ASP.NET Core 3.1 + EntityFramework Coreを使ってアプリを書いた際、1モデルのデータを返す方法はチュートリアルにありました。

 

一方、一対多のリレーションがあるデータをどうやってレスポンスすれば良いか、最初のうちは分かりませんでした。

そのため、色々と試したことをメモしておきます。

 

目次

 

環境

 

環境構築

まずは、MacASP.NET Core プロジェクトを作るまでの環境構築です。

 

.NET Core SDKのインストール

.NET Core SDKをHomebrewで管理しようと調べてみたところ、caskにありました。

$ brew search dotnet-sdk
==> Casks
dotnet-sdk

 

そのため、caskを使ってインストールしました。

$ brew cask install dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
🍺  dotnet-sdk was successfully installed!

 

これで dotnet コマンドが使えるようになりました。

$ dotnet --version
3.1.100

 
なお、SDKのバージョンアップする場合は、以下のコマンドで行います。

$ brew cask upgrade dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
==> Purging files for version 3.0.101,1b9f265d-ba27-4f0c-8b4d-1fd42bd8448b:2bbd64abddeeea91149df3aa39d049ae of Cask dotnet-sdk
🍺  dotnet-sdk was successfully upgraded!

 

ソリューションとプロジェクトを作る

今回は、dotnetコマンドを使って作成します。

 

ソリューションファイルの作成
$ dotnet new sln

 

プロジェクトファイルの作成

今回は、 EFCoreRelationSample というプロジェクトを、Web APIテンプレートを使って作成します。

 

まずは用意されているテンプレートを確認します。今回は webapi を使えば良さそうです。

$ dotnet new --list
...

Templates                                         Short Name                                 
------------------------------------------------------------                            
ASP.NET Core Web App (Model-View-Controller)      mvc                              
...
ASP.NET Core Web API                              webapi
...

 

プロジェクトを作成し、ソリューションに追加します。

# プロジェクトを作成
$ dotnet new webapi -o EFCoreRelationSample

# ソリューションに追加
dotnet sln add ./EFCoreRelationSample

 

EntityFramework CoreまわりのNuGetパッケージを追加

まずはSDKのグローバルに、EntityFramework Core (以下、 EF Core )まわりのコマンドを追加します。
EF Core ツールリファレンス (.NET CLI)-EF Core | Microsoft Docs

$ dotnet tool install --global dotnet-ef
次のコマンドを使用してツールを呼び出せます。dotnet-ef
ツール 'dotnet-ef' (バージョン '3.1.0') が正常にインストールされました。

なお、 dotnet ef コマンドは .NET Core SDKに同梱されていません。
Announcing Entity Framework Core 3.1 and Entity Framework 6.4 | .NET Blog

 

続いて、EF Core関連のパッケージを追加します。

# プロジェクトのあるディレクトリへ移動
$ cd EFCoreRelationSample/

# 今回SQLiteを使うため、SQLiteパッケージを追加
$ dotnet add package Microsoft.EntityFrameworkCore.SQLite

# マイグレーションなどのために追加
$ dotnet add package Microsoft.EntityFrameworkCore.Design

 
なお、 Microsoft.EntityFrameworkCore.SQLite の制限内容はこちら。 SQLite Database Provider - Limitations - EF Core | Microsoft Docs

 
また、Microsoft.EntityFrameworkCore.Design を追加する目的はこちら。
c# - Purpose of package "Microsoft.EntityFrameworkCore.Design" - Stack Overflow

 

ここまでで、ASP.NET Core & EF Coreの環境ができました。

 

モデルまわりの作成

今回使用するモデルまわりを作成します。

 

モデルの作成

今回作成する一対多のリレーションを持つモデルとして、BlogとPostを使います。公式チュートリアルとほぼ同じ型です。
リレーションシップ-EF Core | Microsoft Docs

 

テンプレートにはモデルを入れるディレクトリがないため、

  • Controllersと同列に Models というディレクトリを作成
  • Modelsの中にモデルファイル BlogPostOfFk.cs を作成

とします。

 

2つのモデルのリレーションについて、今回はナビゲーションプロパティを使って定義します。

EF Coreの規則として、親側にだけ定義すれば、自動でリレーションが貼られるようです。
https://docs.microsoft.com/ja-jp/ef/core/modeling/relationships?tabs=data-annotations#single-navigation-property

 
ただ、後述のControllerにて親から子をたぐる処理を書く際、ナビゲーションプロパティが定義されていない状態ではコンパイルが通りませんでした。

var blog = await _context.BlogOfFks
    .Include(p => p.PostsOfFk)  // この p.PostOfFk が参照できなくてエラー
    .ToListAsync();
return Ok(blog);

 
そのため、以下のように明示的に定義します。なお、外部キーについては定義しなくても動作するようです。

using System.Collections.Generic;

namespace EFCoreRelationSample.Models
{
    public class BlogOfFk
    {
        public int Id { get; set; }
        public string Url { get; set; }
        
        public List<PostOfFk> PostsOfFk { get; set; }
    }

    public class PostOfFk
    {
        public int Id { get; set; }
        public string Content { get; set; }

        public BlogOfFk BlogOfFk { get; set; }
    }
}

 

DbContextの作成

モデルができたので、次はDBと接続するためのDbContextを作成します。

DbContextの適切な置き場が分からなかったため、今回は Models の下に MyContext.cs として作成しました。

DbContextを継承した MyContext クラスを作成し、

  • コンストラク
  • DbSet型のpublicプロパティ

を用意します。

using Microsoft.EntityFrameworkCore;

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        public MyContext(DbContextOptions<MyContext> options) : base(options) {}
        
        // モデルクラスへのアクセス (DbSet<TModel>) 型のpublicプロパティ
        public DbSet<BlogOfFk> BlogOfFks { get; set; }
        public DbSet<PostOfFk> PostOfFks { get; set; }
    }
}

 

接続文字列の作成 (appsettings.json)

次に、SQLiteへの接続するための接続文字列を用意します。

今回は、プロジェクトルート直下にある appsettings.json の中に記載します。

接続するSQLiteは、プロジェクトルート直下の WebApplication.db とします。

{
  // ...
  "AllowedHosts": "*",
  
  // 以下を追加
  "ConnectionStrings": {
    "MyContext": "Data Source=./WebApplication.db"
  }
}

なお、SQLiteファイルはEF Coreのマイグレーションにより自動生成されます。

 

Startup.csに、DbContextと接続文字列を追加

今回使用するDbContextと接続文字列を追加します。

SQLiteへ接続するため、 UseSqlite() を使います。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    // 以下を追加
    services.AddDbContext<MyContext>(options => options.UseSqlite(
        Configuration.GetConnectionString("MyContext")));
}

 

マイグレーションの作成

モデルまわりができたため、EF Coreによるマイグレーションを作成します。

 

マイグレーションファイルの作成

以下のコマンドを実行すると、 Migrations ディレクトリの中にマイグレーションファイルが作成されます。

$ dotnet ef migrations add InitialCreate

今回は

  • MyContextModelSnapshot.cs
  • <タイムスタンプ>_InitialCreate.cs

の2ファイルが作成されました。

 

データベースへの反映

マイグレーションファイルの内容をSQLiteへ反映します。

$ dotnet ef database update

 
実行時のログより、BlogOfFk・PostOfFk モデルを元に、2つのテーブルを作成するSQLが発行されたことが分かります。

BlogOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "BlogOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_BlogOfFks" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

PostOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "PostOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_PostOfFks" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogOfFkId" INTEGER NULL,
          CONSTRAINT "FK_PostOfFks_BlogOfFks_BlogOfFkId" FOREIGN KEY ("BlogOfFkId") 
              REFERENCES "BlogOfFks" ("Id") ON DELETE RESTRICT
      );

モデルにはナビゲーションプロパティだけ定義したものの、子モデルの外部キー BlogOfFkId が自動生成されました。

一方、モデルに定義した

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

は、テーブルのフィールドとして作成されませんでした。

 

初期データの投入

初期データを投入するため、データのシード処理を実装します。
データシード処理-EF Core | Microsoft Docs

投入方法はいくつかあるようですが、今回はDbContextの中で実装します。

OnModelCreating() メソッドをオーバーライドして実装します。

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        // ...
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // データのシードを定義
            // 親データ
            modelBuilder.Entity<BlogOfFk>().HasData(
                new {Id = 1, Url = "https://example.com/foo"},
                new {Id = 2, Url = "https://example.com/bar"}
            );
            
            // 子データ
            modelBuilder.Entity<PostOfFk>().HasData(
                new {Id = 1, Content = "ふー", BlogOfFkId = 1},
                new {Id = 2, Content = "ばー", BlogOfFkId = 1}
            );
        }
    }
}

 

初期データを投入するため、

  • 先ほど作成したマイグレーションファイル (Migrations/ の下) を削除
  • SQLite (WebApplication.db) を削除
  • dotnet ef migrations add InitialCreate
  • dotnet ef database update

を実行したところ

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "BlogOfFks" ("Id", "Url")
      VALUES (1, 'https://example.com/foo');
      
...

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "PostOfFks" ("Id", "BlogOfFkId", "Content")
      VALUES (1, 1, 'ふー');

などのログが表示され、シード処理が行われていることが確認できました。

 
Rider IDEで確認しても、以下のようにデータが存在しています。

BlogOfFk

f:id:thinkAmi:20191217221650p:plain:w400

 

PostOfFk

f:id:thinkAmi:20191217221704p:plain:w400

 

EF Coreから発行したSQLをコンソールへ出力

コントローラを作成する前に、EF Coreで発行したSQLをコンソールで確認する設定を追加しておきます。

基本的には以下の記事の通りですが、.NET Core 3.1版では少々変更がありました。
【C#】Entity Framework Core の SQL の実行ログを確認する - Qiita

 

NuGetパッケージを追加

コンソールへ出力するためのNuGetパッケージを追加します。

$ dotnet add package Microsoft.Extensions.Logging.Console

 

DbContextへ追加

DbContextへ

  • staticなreadonlyフィールドを追加
  • OnConfiguring()をオーバーライドして実装

を追加します。

ただ、.NET Core 3.1ではLoggerFactoryの追加方法が変わったようです。

 

そのため

  • LoggerFactory.Create()で生成
  • AddFilter()で、 DbLoggerCategory.Database.Command.Name を対象にする
  • AddConsole()で、コンソールへ出力

とします。

static readonly ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
    builder
        .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
        .AddConsole();
});

// UseLoggerFactory で上記のロガーファクトリを登録する。
// Insert 文の値をロギングするために EnableSensitiveDataLogging を許可する。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
    optionsBuilder
        .EnableSensitiveDataLogging()
        .UseLoggerFactory(loggerFactory);

 

コントローラの作成

あとはコントローラを作成すれば完成です。

 

失敗:Include()を使用したリレーションデータのレスポンス

コントローラに必要な

  • DbContextフィールド
  • コンストラクタで、DbContextフィールドへの設定
  • HTTP GETでアクセスした時のメソッド

を実装します。

以下は、 /BlogPost/p2c にアクセスした時に、BlogOfFkモデルからPostOfFkモデルをたぐって表示する例となります。

namespace EFCoreRelationSample.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class BlogPostController : ControllerBase
    {
        private readonly MyContext _context;

        public BlogPostController(MyContext context)
        {
            this._context = context;
        }

        // 1 -> N の順でたぐる場合
        [HttpGet("p2c", Name = "Parent2Child")]
        public async Task<IActionResult> P2C()
        {
            var blog = await _context.BlogOfFks
                .Include(p => p.PostsOfFk)
                .ToListAsync();
            return Ok(blog);
        }
    }
}

 

Rider IDEで実行し、ブラウザで https://localhost5000/api/BlogPost/p2c へアクセスすると

An unhandled exception occurred while processing the request.
JsonException: A possible object cycle was detected which is not supported. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32.

というエラーが表示されました。

 
おそらく、モデルに

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

の2つが実装されているため、循環してしまったものと考えられました。

 

対応ですが、ASP.NET Core 2系では

のように、 SerializerSettings.ReferenceLoopHandling を設定すれば良いようでした。

 
ただ、ASP.NET Core 3系では、JSONライブラリに変更が入りました。

その影響で、SerializerSettings.ReferenceLoopHandling を設定するには、NuGetパッケージ Microsoft.AspNetCore.Mvc.NewtonsoftJson を追加する必要がありそうでした。

 
NewtonsoftJson の追加も考えましたが、今回は AutoMapper を使うことで対応することにしました。

 

AutoMapperを使った、リレーションデータのレスポンス

AutoMapperを使うにあたり、以下の記事が参考になりました。
ASP.NET CoreでAutoMapperを使う - ryuichi111stdの技術日記

ただ、ASP.NET Core3.1では少々変更されていました。

そのため、上記記事とstackoverflowを参考に

  • NuGetパッケージの追加
  • DTOクラスを追加
  • Profileクラスを追加
  • Startup.csへの追加
  • コントローラへの追加

を行いました。
c# - How to set up Automapper in ASP.NET Core - Stack Overflow

 

NuGetパッケージの追加

必要なNuGetパッケージを追加します。

# AutoMapper本体を追加
dotnet add package AutoMapper

# 拡張も入れる
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

 

DTOクラスを追加

ブラウザ側へ返す時のデータ型となるDTOクラスを追加します。

今回は DTO/BlogPostOfFKDTO.cs として追加しました。

また、DTOのプロパティ名はモデルと同一としました。

using System.Collections.Generic;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class PostOfFkDTO
    {
        // Postのフィールド
        public int Id { get; set; }
        public string Content { get; set; }
        
        // FKのフィールド
        public BlogOfFk BlogOfFk { get; set; }
    }

    public class BlogOfFkDTO
    {
        // Blogのフィールド
        public int Id { get; set; }
        public string Url { get; set; }

        // FK(Post)のフィールド
        public List<PostOfFk> PostsOfFk { get; set; }
    }   
}

 

Profileクラスを追加

次に、モデルとDTOを結びつけるProfileクラスを追加します。

置き場所が分からなかったため、今回は DTO/MyProfile.cs として作成しました。

Profileクラスでは CreateMap() を使って、モデルとDTOを結びつけます。

using AutoMapper;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class MyProfile : Profile
    {
        public MyProfile()
        {
            CreateMap<BlogOfFk, BlogOfFkDTO>();
            CreateMap<PostOfFk, PostOfFkDTO>();
        }
    }
}

 

Startup.csへの追加

ConfigureServices() に、 AddAutoMapper() を追加します。

public void ConfigureServices(IServiceCollection services)
{
    ...
    // 【追加】AutoMapperを追加
    services.AddAutoMapper(typeof(MyProfile));
}

 

コントローラへの追加

まず、

  • IMapper型のフィールドを追加
  • コンストラクタに IMapper 型の引数を追加

を行います。

public class BlogPostController : ControllerBase
{

    private readonly IMapper _mapper;

    public BlogPostController(MyContext context, IMapper mapper)
    {
        this._context = context;
        this._mapper = mapper;
    }
    ...
}

 

続いてコントローラのメソッドを書き換えます。

ただ、今回はAutoMapper有無を比較できるよう P2CWithAutoMapper() を新しく作成してみました。

AutoMapperを使う場合は、 ProjectTo() を追加することで、DTOへの変換を行っています。

[HttpGet("p2c-auto", Name = "Parent2ChildWithAutoMapper")]
public async Task<IActionResult> P2CWithAutoMapper()
{
    var blogs = await _context.BlogOfFks
        .Include(p => p.PostsOfFk)
        .ProjectTo<BlogOfFkDTO>(_mapper.ConfigurationProvider)  // 追加
        .ToListAsync();
    return Ok(blogs);
}

ちなみに、同じような機能に Map() もあります。 ProjectTo() との違いは、こちらが参考になりました。
LINQ to Entities の遅延評価と AutoMapper が便利という話 - Qiita  

Rider IDEASP.NET Coreを起動した時の動作を変更

あとは確認するだけです。

ただ、Rider IDEASP.NET Coreを起動した時の動作を変更したいため、 Properties/launchSettings.json を修正します。

  • launchBrowser で、起動した時にブラウザも自動で開くか
  • launchUrl で、ブラウザで最初にアクセスするURLを指定

などができます。

"EFCoreRelationSample": {
  "commandName": "Project",
  "launchBrowser": true,
  "launchUrl": "api/BlogPost",
  "applicationUrl": "https://localhost:5001;http://localhost:5000",
  "environmentVariables": {
    "ASPNETCORE_ENVIRONMENT": "Development"
  }
}

 

動作確認

ASP.NET Coreアプリを起動し、ブラウザで https://localhost5000/api/BlogPost/p2c-auto を開くと、リレーションデータのJSONが表示されました。

f:id:thinkAmi:20191217221737p:plain

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi-sandbox/ASP_Net_Core-sample

なお、マイグレーションファイルは自動生成されたものなので、GitHubには含めていません。

Pythonで、unittest.mock.patchを使って定数を差し替える

小ネタです。

以前、 unittest.mock.patch を使ってデコレータを差し替えたことがありました。
Pythonで、unittest.mock.patchを使ってデコレータを差し替える - メモ的な思考的な

 
今回は、unittest.mock.patch を使って定数を差し替えてみます。

 
目次

 

環境

  • Python 3.7.5
  • pytest 5.3.1
    • テストランナーとしてのみ、使用

 

定数の差し替え

デコレータの時と同様、今回の定数の差し替えでも unittest.mock.patch() を使います。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.patch

 
デコレータや関数の場合は、 side_effectreturn_value を使いました。

ただ、定数の場合は単にオブジェクトの差し替えだけのため、第2引数(もしくは new キーワード引数)で、差し替え後の値を渡してあげます。

 
たとえば、 target.py というファイルに、以下のようなプロダクションコードがあるとします。

DEBUG = True

def get_mode():
    return 'デバッグ' if DEBUG else 'プロダクション'

これに対し、定数 DEBUGFalse に差し替えたテストコードを書きたいとします。

 
この場合、

from target import get_mode
from unittest.mock import patch


@patch('target.DEBUG', False)
def test_production():
    actual = get_mode()
    assert actual == 'プロダクション'


@patch('target.DEBUG', new=False)
def test_production_with_new_keyword():
    actual = get_mode()
    assert actual == 'プロダクション'

とすることで、定数 DEBUG の値が TrueからFalseへと差し替わります。

 
また、もしテストの一時期だけDEBUGの値を差し替えたいという場合には、patchをwith文とともに使います。

def test_patch_using_with():
    # with文の中では、定数の値がFalseになる
    with patch('target.DEBUG', False) as p:
        actual1 = get_mode()
        assert actual1 == 'プロダクション'

    # with文を抜けると、定数の値がTrueに戻る
    actual2 = get_mode()
    assert actual2 == 'デバッグ'

 

その他参考

unit testing - How to patch a constant in python - Stack Overflow

 

ソースコード

Githubに上げました。ディレクトe.g._patch_constant が今回のものです。
https://github.com/thinkAmi-sandbox/python_mock-sample