Windows for Docker & docker composeにて、top-level volumes option で named volume を定義してPostgreSQLのデータを永続化する

WindowsPostgreSQLを使って開発する際、コンテナを破棄してもデータが残るデータの永続化を考えました。

ただ、macと同じように

version: '3'
services:
  postgres:
    image: postgres:12.2-alpine
    tty: true
    restart: always

    volumes:
      # pgdataをホストに置く
      - ./pgdata:/var/lib/postgresql/data

    stdin_open: true
    ports:
      - "44321:5432"

と、pgdata というローカルのファイルシステム上のフォルダを指定したところ

postgres_1  | running bootstrap script ... 2020-03-27 15:56:59.023 UTC [51] FATAL:  data directory "/var/lib/postgresql/data" has wrong ownership
postgres_1  | 2020-03-27 15:56:59.023 UTC [51] HINT:  The server must be started by the user that owns the data directory.
postgres_1  | child process exited with exit code 1
postgres_1  | initdb: removing contents of data directory "/var/lib/postgresql/data"

というエラーが発生してPostgreSQLが起動しませんでした。

 
そんな中、同僚の @moon_in_nagano よりデータボリュームについて教わったため、メモを残します。

 

目次

 

環境

 
また、PostgreSQLでデータの永続化ができているかを確認するのに、Pythonを使いました。

  • Python 3.8.2
  • SQLAlchemy 1.3.15
  • psycopg2 2.8.4

 
テーブル作成 & データ投入のスクリプトは以下です。

# insert.py

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)


if __name__ == "__main__":

    engine = create_engine('postgresql://{user}:{password}@localhost:{port}/{database_name}'.format(
        user='postgres',
        password='postgres',
        port=44321,
        database_name='postgres',
    ))
    Base.metadata.create_all(engine)

    Session = sessionmaker(bind=engine)
    session = Session()
    session.add(User(name='foo'))
    session.commit()

 

対応

データボリュームを使うにはコマンドラインのオプションを指定すれば良さそうでした。
Docker の Data Volume まわりを整理する - Qiita

docker-composeでの方法を調べたところ、 top-level volumes option を使えば良さそうでした。
Compose file version 3 reference | Docker Documentation

 

top-level volumes optionの設定

docker-compose.ymlでnamed volumeを設定します。

まずは、トップレベルにvolumeの記載を追加します。今回は pgdata というvolume名とします。

volumes:
  pgdata:

 
次に、serviceのvolumeの中で pgdata を使うように修正します。

services:
  postgres:
    image: postgres:12.2-alpine
    ...
    volumes:
      # ファイルシステムの指定をやめて、PostgreSQLのデータを名前付きボリュームを使って永続化
      # - ./pgdata:/var/lib/postgresql/data
      # top-level volumes optionで指定した "pgdata" を ":" の左側へ設定
      - pgdata:/var/lib/postgresql/data

 
全体はこちら。

# マイナーバージョンを書かないと自動的にマイナーバージョンが"0"になるので、"3.7"と明示的に設定
# https://docs.docker.com/compose/compose-file/compose-versioning/#version-3
version: '3.7'
services:
  postgres:
    image: postgres:12.2-alpine

    environment:
        # エンコーディングを指定しておく
        # initdbで指定できる内容はここで指定可能
        # https://www.postgresql.org/docs/10/static/app-initdb.html を参照。
        POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=ja_JP.UTF-8"
        
        # パスワード
        POSTGRES_PASSWORD: postgres

    # ttyをtrueに設定しておくと、コンテナが起動し続ける
    tty: true
    restart: always

    volumes:
      # ファイルシステムの指定をやめて、PostgreSQLのデータを名前付きボリュームを使って永続化
      # - ./pgdata:/var/lib/postgresql/data
      - pgdata:/var/lib/postgresql/data

    stdin_open: true
    ports:
      - "44321:5432"
        
volumes:
  pgdata:

 

動作確認

あとはいつもどおり起動します。

docker-compose up

 
Pythonスクリプトを流した後、PostgreSQL上にデータが存在することを確認します。

# スクリプトの実行
python insert.py

# コンテナのPostgreSQLを確認
>docker container exec -it temp_postgres_1 sh
/ # psql -U postgres -h 127.0.0.1 -p 5432 postgres
psql (12.1)
Type "help" for help.

postgres=# SELECT * FROM users;
 id | name
----+------
  1 | foo
(1 row)

 
down後に再度upしても、データが残っていることを確認します。

# 停止
docker-compose down

# 起動
docker-compose up

# 確認
## Dockerの中に入ってshを実行
>docker container exec -it temp_postgres_1 sh

## PostgreSQLの中に入る
/ # psql -U postgres -h 127.0.0.1 -p 5432 postgres
psql (12.1)
Type "help" for help.

## SELECT実行
postgres=# SELECT * FROM users;
 id | name
----+------
  1 | foo
(1 row)

 

named volumeの削除

もし不要になった場合は、down時にオプション -v を追加することで、コンテナと同時にさくじょできます。 ( docker volume rm で別々に削除することも可能)

>docker-compose down -v
Stopping temp_postgres_1 ... done                                                                                       Removing temp_postgres_1 ... done                                                                                       Removing network temp_default
Removing volume temp_pgdata

 
再度起動しても、PostgreSQLのデータはありません。

>docker container exec -it temp_postgres_1 sh
/ # psql -U postgres -h 127.0.0.1 -p 5432 postgres
psql (12.1)
Type "help" for help.

postgres=# SELECT * FROM users;
ERROR:  relation "users" does not exist
LINE 1: SELECT * FROM users;

 

その他

手元ではダメだったこと
PostgreSQLのパスからdataを削除する

docker-compose.ymlで、

services:
  postgres:
    image: postgres:12.2-alpine
    volumes:
      # - ./pgdata:/var/lib/postgresql/data
      - ./pgdata:/var/lib/postgresql

と、 ./pgdata:/var/lib/postgresql とやれば動作するというのも見かけました。

ただ、手元では動作はしたものの、データの永続化がなされませんでした。

 

top-level volumes optionで保存先をローカルのファイルシステムにする

このstackoverflowを見ると、top-level volumes optionでもローカルのファイルシステムを指定できそうでした。

そのため、ローカルの D:\temp\pgdata2 に保存するよう

volumes:
  pgdata:
    driver: local
    driver_opts:
        type: none
        device: "/host_mnt/d/temp/pgdata2"
        o: bind

と設定して docker-compose up したところ

postgres_1  | running bootstrap script ... 2020-03-27 22:48:46.370 UTC [50] FATAL:  data directory "/var/lib/postgresql/data" has wrong ownership
postgres_1  | 2020-03-27 22:48:46.370 UTC [50] HINT:  The server must be started by the user that owns the data directory.
postgres_1  | child process exited with exit code 1
postgres_1  | initdb: removing contents of data directory "/var/lib/postgresql/data"

と冒頭と同じようなエラーになりました。ローカルのファイルシステム自体に割り当てるのがダメですね。

 

Windowsにおけるnamed volumeのありか

上記では特にパスを指定していないので、named volumeがどこに置かれるのか分かりませんでした。

調べてみたところ

When running linux based containers on a windows host, the actual volumes will be stored within the linux VM and will not be available on the host's fs, otherwise windows running on windows => C:\ProgramData\Docker\volumes\

Locating data volumes in Docker Desktop (Windows) - Stack Overflow

With Docker for Windows, the volume is saved inside of the VM running Linux which is managed by Docker. It wouldn't be inside of a container, or your Windows box directly.

volume mount point - does not exist (Docker for Windows / WSL) - Course: Docker Fundamentals

とありました。

 
そこで、Docker for WindowsにおけるLinuxコンテナについて調べてみたところ、Microsoftの公式ドキュメントに

- Linux コンテナーを完全な Linux VM で実行する-これは、現在、Docker が行うものです。
- Hyper-v 分離(lcow) を使用して Linux コンテナーを実行する-これは Docker for Windows の新しいオプションです。

https://docs.microsoft.com/ja-jp/virtualization/windowscontainers/deploy-containers/linux-containers

とありました。

手元ではどちらが使われているのかを調べたところ、Hyper-VマネージャーにDockerDesktopVMがいました。

f:id:thinkAmi:20200329090905j:plain:w450

 
また、 C:\Program Files\Linux Containers フォルダ自体がありませんでした。

C:\Program Files>dir Linux*

 C:\Program Files のディレクトリ

ファイルが見つかりません

これらより、手元では Linux コンテナーを完全な Linux VM で実行する の設定でDocker for Windowsが動作しているようです。

 
そのため、Hyper-V上のLinuxコンテナにデータボリュームが保存されており、Windowsファイルシステム上では見れないのだと考えました。

Mac + C# + .NET Core3.1で、ActiveDirectoryのLDAP認証をしてみた

Mac .NET Core 3.1のアプリを開発する中、ユーザーがActiveDirectory (以降、AD)に参加していない環境で、ADサーバーを使ったLDAP認証をする機会があったため、メモを残します。

 
目次

 

環境

  • Mac OSX 10.14.6
  • .NET Core 3.1
  • Novell.Directory.Ldap.NETStandard 3.0.1

 
また、ADまわりは以下の環境です。

項目
ADサーバ Windows Server 2016
ドメインの機能 2016
Macユーザ ドメインに不参加、ただしADユーザのIDとパスワードは分かる
Mac ドメインのコンピュータとして参加済、ただしドメインのComputersへ登録のみ
AD - Mac間接続 直接 (Proxyなし)
ADドメイン sub.example.co.jp

 
ADユーザ情報はこんな感じ

項目
ユーザーログオン名 foo_user@sub.example.co.jp
ユーザーログオン名(Windows2000以前) foo_user
foo
bar
表示名 foo bar

 

そもそも、なぜWindows認証ではないのか

ADを使った認証というと、まずはWindows認証が思い浮かびます。

ASP.NET Core 3.1でWindows認証が可能な方法を調べたところ、以下の3つでした。
ASP.NET Core での Windows 認証を構成します。 | Microsoft Docs

  • IIS (IIS Express)
  • HTTP.sys
  • Kestrel

 
このうち、Mac上で ASP.NET Coreをホストして使えるのは、 Kestrel だけでした。

とはいえ、Kestrelは機能不足なところがありそうなので、できれば避けたいと考えました。
ASP.NET Coreを動かすためのIISの構築方法 - Qiita

 
他に方法がないか考えたところ、以前PythonRubyLDAP認証を使っていたことを思い出しました。

 
そこで、 .NET Coreで使えるLDAP認証ライブラリを探したところ、 dsbenghe/Novell.Directory.Ldap.NETStandard がありました。
dsbenghe/Novell.Directory.Ldap.NETStandard: LDAP client library for .NET Standard 1.3 and up - works with any LDAP protocol compatible directory server (including Microsoft Active Directory).

最近リリースされたばかり(2020年1月下旬)だったので、以下を参考に試してみました。
.NET Core LDAP authentication

 

準備

ソリューションとプロジェクトの作成

今回は、コンソールアプリケーションで作ります。

$ dotnet new sln -n LdapAuth

$ dotnet new console -o LdapAuthConsole

$ dotnet sln add ./LdapAuthConsole

 

NuGetでインストール

Novell.Directory.Ldap.NETStandard をインストールします。

$ cd LdapAuthConsole/
$ dotnet add package Novell.Directory.Ldap.NETStandard

 

使い方の流れ

コネクションの作成

まずはLDAPコネクションを作成します。

using (var connection = new LdapConnection { SecureSocketLayer = false })

 

Bind

続いてBindします。

この時、ADのユーザIDとパスワードが必要になります。Macのユーザとは異なっていて問題ないです。

connection.Connect(ipAddress, port);
connection.Bind(userDn, password);

 
接続が成功した場合は、 connection.Boundtrue になります。

 

ADの検索

検索するには、 connection.Search() を使います。

引数は5つあります。

 

第一引数 (searchBase)

どこを探すかを指定します。

今回はドメインすべてを検索するため、 dc=sub, dc=example, dc=co, dc=jp を指定します。

なお、dcの数を動的に指定したかったため、以下のような関数を用意しました。

これで、 sub.example.co.jpdc=sub, dc=example, dc=co, dc=jp になります。

static string ConvertDomain(string domain)
{
    return domain.Split(".")
        .Select(s => $"dc={s}")
        .Aggregate((result, element) => $"{result}, {element}");
}

 

第二引数 (LdapConnectionクラス)

どの範囲で検索するかを指定します。

今回は、ドメイン内全てを検索するため、 LdapConnection.ScopeSub とします*1

 

第三引数 (フィルタ)

絞り込み条件を指定します。

今回は、ADユーザのログインIDをキーにデータを取得するため、 SAMAccountName を使います。

またあいまい検索もしたいため、 * を使った

$"(SAMAccountName=*{userName}*)"

を指定します。

 

第四引数 (取得したい項目)

ADから取得したい項目をここで指定します。

どのような値を指定できるかは以下が参考になりました。

 
今回は以下を指定しました。

new []
{
    "displayName",        // 表示名
    "cn",                 // 表示名と同じ
    "sn",                 // 姓
    "givenName",          // 名
    "userPrincipalName",  // ユーザーログオン名
    "sAMAccountName",     // ユーザーログオン名(Windows 2000以前)
    "description"         // 説明
},

 
主な引数はこんな感じです。

 

値の取得

Search()で取得した値は、 GetAttribute() を使って取得できます。

var user = result.Next();
var displayName = user.GetAttribute("displayName").StringValue;

 

ソースコード全体

ここまでをまとめると以下の感じです。

using System;
using System.Linq;
using Novell.Directory.Ldap;

namespace LdapAuthConsole
{
    class Program
    {
        static string ConvertDomain(string domain)
        {
            var r = domain.Split(".")
                .Select(s => $"dc={s}")
                .Aggregate((result, element) => $"{result}, {element}");
            
            Console.WriteLine(r);
            return r;
        }
        
        static void Main(string[] args)
        {
            var userName = "foo_user";  // Domain Users
            var password = "YOUR_PASSWORD";
            var domain = "sub.example.co.jp";
            var ipAddress = "192.168.xxx.xxx";
            var port = 389;
            var userDn = $"{userName}@{domain}";
            
            try
            {
                using (var connection = new LdapConnection { SecureSocketLayer = false })
                {
                    connection.Connect(ipAddress, port);
                    connection.Bind(userDn, password);

                    if (connection.Bound){
                        Console.WriteLine("接続できました");
                        
                        // 検索
                        var searchBase = ConvertDomain(domain);
                        
                        // あいまい検索が使える
                        var searchFilter = $"(SAMAccountName=*{userName}*)";
                        var result = connection.Search(
                            searchBase,
                            // ドメイン直下からすべてのサブを調べる
                            LdapConnection.ScopeSub,
                            searchFilter,
                            new []
                            {
                                "displayName",        // 表示名
                                "cn",                 // 表示名と同じ
                                "sn",                 // 姓
                                "givenName",          // 名
                                "userPrincipalName",  // ユーザーログオン名
                                "sAMAccountName",     // ユーザーログオン名(Windows 2000以前)
                                "description"         // 説明
                            },
                            false
                        );
                        var user = result.Next();
                        var displayName = user.GetAttribute("displayName").StringValue;
                        
                        Console.WriteLine(user);
                        Console.WriteLine(displayName);
                    }
                    else
                    {
                        Console.WriteLine("接続できませんでした");
                    }
                }
            }
            catch (LdapException ex)
            {
                // Log exception
                // TODO 例外処理を実装
                Console.WriteLine("例外が出ました");
                Console.WriteLine(ex);
            }
        }
    }
}

 

結果

実行すると、MacでADユーザを使っていなくても、LDAP認証を使ってADから情報を取得できました。

$ dotnet run
接続できました
dc=sub, dc=example, dc=co, dc=jp

# LDAPで取得した中身
LdapEntry: CN=foo bar,CN=Users,DC=sub,DC=example,DC=co,DC=jp; LdapAttributeSet: LdapAttribute: {type='cn', value='foo bar'} LdapAttribute: {type='sn', value='foo'} LdapAttribute: {type='givenName', value='bar'} LdapAttribute: {type='displayName', value='foo bar'} LdapAttribute: {type='sAMAccountName', value='foo_user'} LdapAttribute: {type='userPrincipalName', value='foo_user@ad.jsl.co.jp'}

# GetAttribute()の結果
foo bar

 

ソースコード

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

*1:2系は大文字の名前でしたが、3系はCamelCaseになりました

C# + AutoMapperで、ArrayListをオブジェクトへマッピングする

AutoMapperを使うことで、オブジェクト同士を簡単にマッピングできます。

そんな中、ArrayListをオブジェクトへマッピングすることがあったため、対応したときのメモを残します。

 
目次

 

環境

 
今回は

new ArrayList {"1", "すいか", "夏", 1000};

のようなArrayList

public class Fruit
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Season { get; set; }
    public decimal UnitPrice { get; set; }
}

へとマッピングすることを考えます。

 

ArrayListをオブジェクトへマッピング

以下のstackoverflowを参考にしました。
c# - How to map an array to multiple properties using AutoMapper? - Stack Overflow

 
ただ、AutoMapperからstatic APIが取り除かれたため、それに対応して実装します。
Removing the static API from AutoMapper · Los Techies

 
まずはマッピングです。

// ArrayListの要素をプロパティにマッピング
var config = new MapperConfiguration(c => 
    c.CreateMap<ArrayList, Fruit>()
        .ForMember(d => d.Id, o => o.MapFrom(s => s[0]))
        .ForMember(d => d.Name, o => o.MapFrom(s => s[1]))
        .ForMember(d => d.Season, o => o.MapFrom(s => s[2]))
        .ForMember(d => d.UnitPrice, o => o.MapFrom(s => s[3]))
    );

// インスタンス化
var mapper = config.CreateMapper();

 
続いて変換します。Mapメソッドに、変換先のクラスを指定します。

// 元データ
var source = new ArrayList {"1", "すいか", "夏", 1000};

// 変換
var dest = mapper.Map<Fruit>(source);

 
結果を確認します。マッピングできています。

Console.WriteLine("ArrayList to Fruit");
Console.WriteLine(
    $"ID: {dest.Id}, Name: {dest.Name}, Season: {dest.Season}, UnitPrice: {dest.UnitPrice}");

// =>
// ArrayList to Fruit
// ID: 1, Name: すいか, Season: 夏, UnitPrice: 1000

 

ListをListへマッピング

AutoMapperではListなどもマッピングできますので、試してみます。
Lists and Arrays — AutoMapper documentation

マッピング方法は先ほどと変わりません。

var config = new MapperConfiguration(c => 
    c.CreateMap<ArrayList, Fruit>()
        .ForMember(d => d.Id, o => o.MapFrom(s => s[0]))
        .ForMember(d => d.Name, o => o.MapFrom(s => s[1]))
        .ForMember(d => d.Season, o => o.MapFrom(s => s[2]))
        .ForMember(d => d.UnitPrice, o => o.MapFrom(s => s[3]))
);

var mapper = config.CreateMapper();

 
Map()の使い方が変わります。

Map()の第一引数に変換元のクラスを、第二引数に変換先のクラスをそれぞれ指定します。

// データ
var source = new List<ArrayList>
{
    new ArrayList {"1", "すいか", "夏", 1000},
    new ArrayList {"2", "りんご", "秋", 100},
    new ArrayList {"3", "みかん", "冬", 150}
};

// 変換
var dest = mapper.Map<List<ArrayList>, List<Fruit>>(source);

 
結果です。List同士のマッピングもできました。

Console.WriteLine("List<ArrayList> to List<Fruit>");
foreach (var fruit in dest)
{
    Console.WriteLine(
        $"ID: {fruit.Id}, Name: {fruit.Name}, Season: {fruit.Season}, UnitPrice: {fruit.UnitPrice}");
}

// =>
// List<ArrayList> to List<Fruit>
// ID: 1, Name: すいか, Season: 夏, UnitPrice: 1000
// ID: 2, Name: りんご, Season: 秋, UnitPrice: 100
// ID: 3, Name: みかん, Season: 冬, UnitPrice: 150

 

双方向マッピング

AutoMapperでは、

を同時に設定できます。

逆方向をマッピングするには ReverseMap() を使います。
Reverse Mapping and Unflattening — AutoMapper documentation

通常同じプロパティへ自動的にマッピングしますが、今回はArrayListへのマッピングとなるため、手動でマッピングする必要があります。

そのため、 ConstructUsing() を使って、マッピングを定義しています。
https://docs.automapper.org/en/stable/Queryable-Extensions.html#custom-destination-type-constructors

var config = new MapperConfiguration(c => 
    c.CreateMap<ArrayList, Fruit>()
        .ForMember(d => d.Id, o => o.MapFrom(s => s[0]))
        .ForMember(d => d.Name, o => o.MapFrom(s => s[1]))
        .ForMember(d => d.Season, o => o.MapFrom(s => s[2]))
        .ForMember(d => d.UnitPrice, o => o.MapFrom(s => s[3]))
        
        // 逆マップ
        .ReverseMap()
        .ConstructUsing(x => new ArrayList
        {
            x.Id, x.Name, x.Season, x.UnitPrice
        })
);
var mapper = config.CreateMapper();

 

使い方です。

// 結果
var reverseSource = new Fruit {Id = 1, Name = "すいか", Season = "夏", UnitPrice = 1000};

// 変換
var reverseDst = mapper.Map<ArrayList>(reverseSource);

 
結果確認です。

Console.WriteLine(
    $"ID: {reverseDst[0]}, Name: {reverseDst[1]}, Season: {reverseDst[2]}, UnitPrice: {reverseDst[3]}");

// =>
// 逆方向
// ID: 1, Name: すいか, Season: 夏, UnitPrice: 1000

 

双方向マッピング (List)

Listも可能です。

定義は変わりません。

var config = new MapperConfiguration(c => 
    c.CreateMap<ArrayList, Fruit>()
        .ForMember(d => d.Id, o => o.MapFrom(s => s[0]))
        .ForMember(d => d.Name, o => o.MapFrom(s => s[1]))
        .ForMember(d => d.Season, o => o.MapFrom(s => s[2]))
        .ForMember(d => d.UnitPrice, o => o.MapFrom(s => s[3]))
        
        // 逆マップ
        .ReverseMap()
        .ConstructUsing(x => new ArrayList
        {
            x.Id, x.Name, x.Season, x.UnitPrice
        })
);
var mapper = config.CreateMapper();

 
使い方が変わります。

Map()の第一引数が変換元のList、第二引数が変換先のListを指定します。

var reverseSourceList = new List<Fruit>
{
    new Fruit {Id = 1, Name = "すいか", Season = "夏", UnitPrice = 1000},
    new Fruit {Id = 2, Name = "りんご", Season = "秋", UnitPrice = 100},
    new Fruit {Id = 3, Name = "みかん", Season = "冬", UnitPrice = 150}

};

// 変換
var reverseDestList = mapper.Map<List<Fruit>, List<ArrayList>>(reverseSourceList);

// 結果
foreach (var dst in reverseDestList)
{
    Console.WriteLine(
        $"ID: {dst[0]}, Name: {dst[1]}, Season: {dst[2]}, UnitPrice: {dst[3]}");
}

// =>
// 逆方向List
// ID: 1, Name: すいか, Season: 夏, UnitPrice: 1000
// ID: 2, Name: りんご, Season: 秋, UnitPrice: 100
// ID: 3, Name: みかん, Season: 冬, UnitPrice: 150

 

マッピング設定をクラス化 (Profile)

Profileを使うことで、マッピング設定をクラス化できます。 https://docs.automapper.org/en/stable/Configuration.html#profile-instances

先ほどの双方向マッピングをクラスにします。  

public class AutoMapperProfile : Profile
{
    public AutoMapperProfile()
    {
        CreateMap<ArrayList, Fruit>()
            .ForMember(d => d.Id, o => o.MapFrom(s => s[0]))
            .ForMember(d => d.Name, o => o.MapFrom(s => s[1]))
            .ForMember(d => d.Season, o => o.MapFrom(s => s[2]))
            .ForMember(d => d.UnitPrice, o => o.MapFrom(s => s[3]))
            
            // 逆マップ
            .ReverseMap()
            .ConstructUsing(x => new ArrayList
            {
                x.Id, x.Name, x.Season, x.UnitPrice
            });
    }
}

 
設定クラスを読み込みます。

AutoMapperでは明示的に指定する他、アセンブリから自動で検索することもできます。
http://docs.automapper.org/en/stable/Configuration.html#assembly-scanning-for-auto-configuration

今回はアセンブリを指定してみます。

// Profileを検索
var config = new MapperConfiguration(c => 
    c.AddMaps(new []
    {
        "MyApp",
    })
);

// インスタンス化
var mapper = config.CreateMapper();

 
あとは今までと同じです。

ここでは正方向だけ示しますが、逆方向も動作します。

Console.WriteLine("正方向 (Profile使用)");
var source = new ArrayList {"1", "すいか", "夏", 1000};
var dest = mapper.Map<Fruit>(source);

Console.WriteLine(
    $"ID: {dest.Id}, Name: {dest.Name}, Season: {dest.Season}, UnitPrice: {dest.UnitPrice}");

// =>
// 正方向 (Profile使用)
// ID: 1, Name: すいか, Season: 夏, UnitPrice: 1000

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/AutoMapper-sample

ASP.NET Core 3.1 & Vue.js 上で、Handsontableを動かしてみた

ASP.NET Core 3.1 & Vue.js 上で、Handsontableを動かす機会があったため、メモを残します。

 
目次

 

環境

 

環境構築

ASP.NET Core 3.1向けのVue.jsプロジェクトテンプレートについて

RiderなどのIDEでは、ASP.NET Coreで使えるVue.jsのテンプレートがGUIでは選択できません。

Vue向けの公式テンプレートは、dotnetコマンドでインストールできるようですが、試してみたところ、Core3.x系のテンプレートにはなりませんでした。
ASP.NET Core のテンプレートで Vue をインストール - Qiita

ほかのテンプレートを探したところ、starの多い aspnetcore-Vue-starter がありました。
TrilonIO/aspnetcore-Vue-starter: NEW Asp.net Core & Vue.js (ES6) SPA Starter kit - Vuex, webpack, Web API, Docker, and more! By @TrilonIO

ただ、こちらもCore3系には対応していませんでした。
Please Upgrade to Core 3 · Issue #138 · TrilonIO/aspnetcore-Vue-starter

Core3系に対応したテンプレートを探したところ、以下がありました。
SoftwareAteliers/asp-net-core-vue-starter: ASP.NET Core + Vue.js starter project

また、

(Optional) Scaffold Vue.js app with custom configuration

と、Vue.jsのカスタマイズもできそうでした。

そのため、このテンプレートを使うことにしました。

 

ASP.NET & Vue.jsの環境構築
テンプレートのインストール
dotnet new -i SoftwareAteliers.AspNetCoreVueStarter

 
テンプレートのリストを見ると、他のVue.js向けテンプレートをインストールしていたせいか、Short Nameが重複してしまいました。

そのため、テンプレートを使う時は、 Templatesに記載されている .NET Core Vue.js を指定します。

$ dotnet new list

Templates                                         Short Name
------------------------------------------------------------
...
ASP.NET Core with Vue.js                          vue       
...                         
.NET Core Vue.js                                  vue       

 

プロジェクトの作成
# ソリューションファイルを作成
$ dotnet new sln -n HandsonTableVueOnAspNetCore

# テンプレート名を使って、プロジェクトを作成
$ dotnet new ".NET Core Vue.js" -o HandsonTableVueOnAspNetCore

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

# Vue.jsのカスタマイズをするため、READMEにある通り、ClientAppを削除
$ rm -rf ClientApp/

 

Vue CLIによる、Vue.jsの環境構築

READMEに従い、 client-app という名前で生成しました。

ひとまずRouterだけ追加しておきます。

$ vue create client-app
Vue CLI v4.1.2
? Please pick a preset: Manually select features
? Check the features needed for your project: Router
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

 

TypeScriptの追加

client-appに対し、TypeScriptを追加します。

ただし、今回はTypeScript化までは行わないため、不要な場合はパスしても大丈夫です。

現時点では、Handsontable向けには、TypeScriptは3.6系を使っておくのが良さそうなので、バージョン指定してインストールします。
Import declaration conflicts with local declaration of 'HotTableProps'. · Issue #145 · handsontable/vue-handsontable-official

$ cd client-app/
$ npm install --save-dev typescript@3.6.4
+ typescript@3.6.4

 
続いて、Vue.jsに追加します。

TSLintの代わりにESLintを使うため、TSLint以外はデフォルトのままで進めます。

ワーニングが出ますが、とりあえずこのままで進めます。

$ vue add typescript
? Still proceed? Yes

✔  Successfully installed plugin: @vue/cli-plugin-typescript

? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use TSLint? No
? Convert all .js files to .ts? Yes
? Allow .js files to be compiled? No

WARN  conflicting versions for project dependency "typescript":

- ^3.6.4 injected by generator "undefined"
- ~3.5.3 injected by generator "@vue/cli-plugin-typescript"

Using newer version (^3.6.4), but this may cause build errors.

 

ESLintの追加
$ npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-vue
+ eslint@6.8.0
+ eslint-plugin-vue@6.1.2
+ @typescript-eslint/parser@2.14.0
+ @typescript-eslint/eslint-plugin@2.14.0

 

Prettier
$ npm install --save-dev prettier eslint-plugin-prettier eslint-config-prettier
+ prettier@1.19.1
+ eslint-config-prettier@6.9.0
+ eslint-plugin-prettier@3.1.2

 

client-app内にある、不要なgitリポジトリを削除

今回はASP.NET Coreも含めてリポジトリ管理するため、削除しておきます。

$ rm -rf .git/

 

ディレクトリの名前を変更

READMEにある通り、 client-app から ClientApp へと変更します。

$ cd ..
$ mv client-app/ ClientApp/

 

動作確認

dotnet run コマンドで起動し、ASP.NET Core & Vue.jsが動作していることを確認します。

$ dotnet run

f:id:thinkAmi:20200105230500p:plain:w400

 

Vue.js & Handsontableの環境構築
vue-handsontable-officialのインストール

Vue.js向けとして、Handsontable公式が vue-handsontable-official を提供しています。
handsontable/vue-handsontable-official: Vue Data Grid with Spreadsheet Look & Feel. Official Vue wrapper for Handsontable.

そのため、インストールします。

$ npm install handsontable @handsontable/vue
...
Handsontable is no longer released under the MIT license. Read more about this change on our blog at https://handsontable.com/blog.
+ handsontable@7.3.0
+ @handsontable/vue@4.1.1

 

実装
Handsontableを使ったコンポーネントを作成

ClientApp/src/components/HelloHandsontable.vue としてコンポーネントを作成します。

<template>
    <div>
        <hot-table :settings="hotSettings" />
    </div>
    
</template>

<script>
    import {HotTable} from "@handsontable/vue";
    
    export default {
        name: "HelloHandsontable",
        components: {
            HotTable
        },
        data() {
            return {
                hotSettings: {
                    // 非商用向けのライセンス
                    licenseKey: 'non-commercial-and-evaluation',
                    
                    data: [
                        [1, "紅あずま", 10],
                        [2, "紅はるか", 20],
                        [3, "シルクスイート", 30],
                    ],
                    colHeaders: ["No", "Name", "Price"],
                    rowHeaders: ["1st", "2nd", "3rd"],
                    
                    // コンテキストメニューまわり
                    // contextMenu: true,  // trueにすると、ブラウザのコンテキストメニューが表示されない
                    allowInsertColumn: false,
                    allowRemoveColumn: false,
                    
                    // 列のソートインディケータを表示
                    columnSorting: {
                        indicator: true
                    }
                }
            }
        }
    }
</script>

<style>
    @import '~handsontable/dist/handsontable.full.css';

    /* 列ヘッダの色を変更する */
    .handsontable thead th .relative {
        background-color: deepskyblue;
    }
</style>

 

App.vueの修正

templateタグの差し替えと、script内にコンポーネントを追加します。

<template>
  <div id="app">
    <!-- Handsontableの表示へと変更
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
    -->
    <div>
      <h3>Hello Handsontable</h3>
      <HelloHandsontable />
    </div>
  </div>
</template>

<script lang="ts">
// 追加
import HelloHandsontable from "@/components/HelloHandsontable.vue";

@Component({
  components: {
    HelloHandsontable,  // 追加
    HelloWorld,
  },
})
export default class App extends Vue {}
</script>

 

開発証明書の適用

もし開発証明書の信頼を行っていない場合、行っておきます。
Windows および macOS で ASP.NET Core HTTPS 開発証明書を信頼する | ASP.NET Core に HTTPS を適用する | Microsoft Docs

$ dotnet dev-certs https --trust

 

動作確認

ここまでで、ASP.NET Core & Vue.js & Handsontableの環境ができましたので、動作を確認します。

dotnet run コマンドで起動します。

$ dotnet run

 
https://localhost:5000 へアクセスし、以下が表示されればOKです。

f:id:thinkAmi:20200105230555p:plain:w400

 

ASP.NET CoreでJSONを返すAPIの作成と連携

まずは、定形JSONを返すASP.NET Core APOIを作成し、そのレスポンスをHandsontableへと反映させてみます。

 

Vueコンポーネントの作成

先ほどと同じようなコンポーネントを作成します。

違いとしては、

  • hotSettingsに渡す data の初期値は、空の配列にする
  • created()で、APIからJSONを受け取り、Handsontableに反映する

となります。

<template>
    <div>
        <hot-table :settings="hotSettings" />
    </div>
    
</template>

<script>
    import {HotTable} from "@handsontable/vue";
    
    export default {
        name: "ApiResponseHandsontable",
        components: {
            HotTable
        },
        data() {
            return {
                hotSettings: {
                    // 非商用向けのライセンス
                    licenseKey: 'non-commercial-and-evaluation',
                    
                    // 初期データなし
                    data: [],
                    // Name列だけ、幅を指定する
                    colWidths: [null, 200, null],
                    
                    colHeaders: ["No", "Name", "Price"],
                    rowHeaders: ["1st", "2nd", "3rd"],
                    
                    // コンテキストメニューまわり
                    // contextMenu: true,  // trueにすると、ブラウザのコンテキストメニューが表示されない
                    allowInsertColumn: false,
                    allowRemoveColumn: false,
                    
                    // 列のソートインディケータを表示
                    columnSorting: {
                        indicator: true
                    }
                }
            }
        },
        created: function() {
            // ロードされた時にAPIを呼んで、Handsontableの初期値を取得する
            fetch('/api/const')
                .then(res => { return res.json() })
                .then(data => {
                    this.hotSettings.data = JSON.parse(data);
                })
        },
    }
</script>

<style>
    @import '~handsontable/dist/handsontable.full.css';

    /* 列ヘッダの色を変更する */
    .handsontable thead th .relative {
        background-color: deepskyblue;
    }
</style>

 

App.vueの修正

コンポーネントを追加します。

<template>
  <div id="app">
    <!-- Handsontableの表示へと変更
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
    -->
    <div>
      <h3>Hello Handsontable</h3>
      <HelloHandsontable />
    </div>
    
    <hr>
    
    <div>
      <h3>Const Response Handsontable</h3>
      <ConstResponseHandsontable />
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
//...
// 追加
import ConstResponseHandsontable from "@/components/ConstResponseHandsontable.vue";

@Component({
  components: {
    ConstResponseHandsontable,  // 追加
    HelloHandsontable,
    HelloWorld,
  },
})
export default class App extends Vue {}
</script>

 

ASP.NET Coreのコントローラーを作成

/api/const にアクセスしたときにJSONレスポンスを返すコントローラーを作成します。

コントローラークラスに

  • ControllerBaseを継承
  • [Route("api/")] 属性で、APIのルーティングを設定
  • [ApiController] 属性で、API向けコントローラーとして動作するよう設定
  • [Produces(MediaTypeNames.Application.Json)] 属性で、レスポンスをJSONにするように指定

とします。

あとは、 GetConstResponse() メソッドに、JSONでレスポンスする内容を記載します。

 
なお、Handsontableのデータ投入メソッド(loadData())に不具合があるようで、7.3.0時点では配列の配列でしかデータを投入できないようです。
Using loadData on an object data doesn't work · Issue #4204 · handsontable/handsontable

8系がリリースされるとオブジェクト配列で投入できそうなので、その時は以下のコードは書き換えたほうが良いかもしれません。

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Text.Json;
using Bogus;
using HandsonTableVueOnAspNetCore.Models;
using Microsoft.AspNetCore.Mvc;

namespace HandsonTableVueOnAspNetCore.Controllers
{
    [Route("api/")]
    [ApiController]
    [Produces(MediaTypeNames.Application.Json)]
    public class HandsontableApiController : ControllerBase
    {
        [HttpGet("const")]
        public IActionResult GetConstResponse()
        {
            var apples = new List<dynamic>
            {
                new List<dynamic> {1, "秋映", 100},
                new List<dynamic> {2, "シナノゴールド", 200},
                new List<dynamic> {3, "ピンクレディ", 300}
            };
            return Ok(JsonSerializer.Serialize(apples));
        }
    }
}

 

動作確認

まずはAPIの動作を確認します。

localhost:5000/api/const にアクセスすると、JSONが返ってくればOKです。

$ curl http://localhost:5000/api/const
"[[1,\"\\u79CB\\u6620\",100],[2,\"\\u30B7\\u30CA\\u30CE\\u30B4\\u30FC\\u30EB\\u30C9\",200],[3,\"\\u30D4\\u30F3\\u30AF\\u30EC\\u30C7\\u30A3\",300]]

 

続いて、 localhost:5000 にアクセスし、JSONの内容がHandosontableに反映されていればOKです。

f:id:thinkAmi:20200105230523p:plain:w400

 

ASP.NET CoreでモデルのJSONを返すAPIの作成と連携

最後に、ASP.NET CoreのモデルをHandsontableに表示してみます。

 

環境構築

今回はEntityFramework Core & SQLiteを使うため、必要なパッケージを追加します。

dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SQLite

また、念のため、発行されたSQLの中身もコンソール出力するため、パッケージを追加します。

dotnet add package Microsoft.Extensions.Logging.Console

 

Vue.jsのコンポーネントを作成

create()でアクセスするAPIのエンドポイントが変更となっただけで、あとは同じです。

<template>
    <div>
        <hot-table :settings="hotSettings" />
    </div>
    
</template>

<script>
    import {HotTable} from "@handsontable/vue";
    
    export default {
        name: "ModelResponseHandsontable",
        components: {
            HotTable
        },
        data() {
            return {
                hotSettings: {
                    // 非商用向けのライセンス
                    licenseKey: 'non-commercial-and-evaluation',
                    
                    // 初期データなし
                    data: [],
                    // Name列だけ、幅を指定する
                    colWidths: [null, 200, null],
                    
                    colHeaders: ["No", "Name", "Age"],
                    rowHeaders: ["1st", "2nd", "3rd"],
                    
                    // コンテキストメニューまわり
                    // contextMenu: true,  // trueにすると、ブラウザのコンテキストメニューが表示されない
                    allowInsertColumn: false,
                    allowRemoveColumn: false,
                    
                    // 列のソートインディケータを表示
                    columnSorting: {
                        indicator: true
                    }
                }
            }
        },
        created: function() {
            // ロードされた時にAPIを呼んで、Handsontableの初期値を取得する
            fetch('/api/model')
                .then(res => { return res.json() })
                .then(data => {
                    this.hotSettings.data = JSON.parse(data);
                })
        },
    }
</script>

<style>
    @import '~handsontable/dist/handsontable.full.css';

    /* 列ヘッダの色を変更する */
    .handsontable thead th .relative {
        background-color: deepskyblue;
    }
</style>

 

App.vueの修正

こちらも、コンポーネントを追加するだけです。

<template>
  <div id="app">
    <!-- Handsontableの表示へと変更
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
    -->
    <div>
      <h3>Hello Handsontable</h3>
      <HelloHandsontable />
    </div>
    
    <hr>
    
    <div>
      <h3>Const Response Handsontable</h3>
      <ConstResponseHandsontable />
    </div>
    
    <hr>
    
    <div>
      <h3>Model Response Handsontable</h3>
      <ModelResponseHandsontable />
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HelloWorld from './components/HelloWorld.vue';
import HelloHandsontable from "@/components/HelloHandsontable.vue";
import ConstResponseHandsontable from "@/components/ConstResponseHandsontable.vue";

// 追加
import ModelResponseHandsontable from "@/components/ModelResponseHandsontable.vue";

@Component({
  components: {
    ModelResponseHandsontable,  // 追加
    ConstResponseHandsontable,
    HelloHandsontable,
    HelloWorld,
  },
})
export default class App extends Vue {}
</script>

 

モデルの作成

Models/Customer.cs として作成します。

namespace HandsonTableVueOnAspNetCore.Models
{
    public class Customer
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

 

DbContextの作成

Models/HandsontableContext.cs として、

  • ログをコンソールに出力
  • SQLiteを使う

のDbContextを作成します。

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

namespace HandsonTableVueOnAspNetCore.Models
{
    public class HandsontableContext : DbContext
    {
        public HandsontableContext(DbContextOptions<HandsontableContext> options) : base(options) {}

        public DbSet<Customer> Customers { 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);
        }
    }
}

 

appsettings.Development.jsonの修正

SQLite向けの接続文字列を追加します。

今回は開発環境なので、 appsettings.Development.json ファイルに追加します。

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  },

  // 以下を追加
  "ConnectionStrings": {
    "HandsontableContext": "Data Source=./WebApplication.db"
  }
}

 

Startup.csの修正

ConfigureServicesに、DbContextを追加します。

public void ConfigureServices(IServiceCollection services)
{
    //...

    // 追加
    services.AddDbContext<HandsontableContext>(options => options.UseSqlite(
        Configuration.GetConnectionString("HandsontableContext")));
}

 

マイグレーション

モデルとDbContextができたため、マイグレーションを行います。

# マイグレーションファイルを作成
$ dotnet ef migrations add InitialCreate

# SQLiteへ反映
$ dotnet ef database update

 

コントローラーの修正
Seed用URLを作成

デフォルトデータとして投入する方法として以前は OnModelCreating() をオーバーライドしました。
データシード処理-EF Core | Microsoft Docs

ただ、今回は何度でも使えるよう、Seed用URLを作成します。
c# - How to seed in Entity Framework Core 2? - Stack Overflow

また、デフォルトデータはランダムなもので良いので、 Bogus を使います。
bchavez/Bogus: A simple and sane fake data generator for C#, F#, and VB.NET. Based on and ported from the famed faker.js.

パッケージをインストールします。

$ dotnet add package Bogus

あとは、コントローラーでDbContextを受け取り、Seed処理を実装します。

GETメソッドでのSeedでいいのか感はありますが、開発用途なのでヨシとします。

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Text.Json;
using Bogus;
using HandsonTableVueOnAspNetCore.Models;
using Microsoft.AspNetCore.Mvc;

namespace HandsonTableVueOnAspNetCore.Controllers
{
    [Route("api/")]
    [ApiController]
    [Produces(MediaTypeNames.Application.Json)]
    public class HandsontableApiController : ControllerBase
    {
        // 追加
        private readonly HandsontableContext _context;
        public HandsontableApiController(HandsontableContext context)
        {
            this._context = context;
        }
        
        //...

        // 追加
        [HttpGet("seed")]
        public IActionResult Seed()
        {
            var faker = new Faker<Customer>("ja")
                .RuleFor(r => r.Name, f => $"{f.Name.LastName()} {f.Name.FirstName()}")
                .RuleFor(r => r.Age, f => f.Random.Number(20, 60));

            var fakes = faker.Generate(3);
            
            _context.Customers.AddRange(fakes.ToArray());
            _context.SaveChanges();

            var customers = fakes.Select(a => new ArrayList
            { 
                a.Id, a.Name, a.Age
            });

            return Ok(JsonSerializer.Serialize(customers));
        }
    }
}

 

モデルからデータを読み込んでJSONレスポンスするAPI作成

同じくコントローラーに追加します。

/api/modelJSONレスポンスを行います。

[HttpGet("model")]
public IActionResult GetApplesResponse()
{
    var response = _context.Customers.Select(c => new ArrayList
    {
        c.Id, c.Name, c.Age
    });
    
    return Ok(JsonSerializer.Serialize(response));
}

 

動作確認

データベースへのSeedを行います。

$ curl http://localhost:5000/api/seed
"[[1,\"\\u9AD8\\u6A4B \\u9678\\u6597\",40],[2,\"\\u677E\\u672C \\u7F8E\\u7FBD\",56],[3,\"\\u658E\\u85E4 \\u592A\\u4E00\",52]]"

 
モデルAPIのレスポンスも確認します。

$ curl http://localhost:5000/api/model
"[[1,\"\\u9AD8\\u6A4B \\u9678\\u6597\",40],[2,\"\\u677E\\u672C \\u7F8E\\u7FBD\",56],[3,\"\\u658E\\u85E4 \\u592A\\u4E00\",52]]"

 
最後に localhost:5000 にアクセスし、表示できればOKです。 (中身はBogusによるダミーデータです)

f:id:thinkAmi:20200105230624p:plain:w400

 

ソースコード

Githubに上げました。
thinkAmi-sandbox/Handsontable_On_ASP_NET_Core_Vue-sample

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

C# で、「ジェネリッククラスを継承し、型引数付コンストラクタを持つクラス」を作成する

C# ではジェネリックを使って、型を引数として渡すことができます。
ジェネリック - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

そんな中、「ジェネリッククラスを継承し、型引数付コンストラクタを持つクラス」を作成する機会があったので、メモに残します。

なお、ジェネリックに関する公式ドキュメントはこちら。
ジェネリック - C# プログラミング ガイド | Microsoft Docs

 
目次

 

環境

  • macOS 10.13.6
  • .NET Core 3.1.0

また、サンプルコードには EntityFramework Core 3.1.0 を使っています。

 

サンプルコード

ジェネリッククラスを継承し、型引数付コンストラクタを持つクラスを作成する では書いている自分もよく分からないので、具体的な例を書いていきます。

 
EntityFramework Coreで発行されるSQLを確認する目的で、

  • エンティティクラス
  • DbContext
  • SQLを確認するために使うテストコード

を実装したものをサンプルとして使います。

 

エンティティクラス

BlogとUserの2つのエンティティクラスがあります。

それらの UrlName という文字列項目に対して、 Fluent APIを使ってNOT NULL制約の有無を制御します。

そのため、 #nullable enable を使って、null許容参照型(string?)として項目を定義します。
null 許容参照型 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

なお、2つのエンティティクラス間にはリレーションがないものとします。

namespace MyApp.GenericInheritance
{
#nullable enable
    public class Blog
    {
        public int Id { get; set; }
        public string? Url { get; set; }
    }

    public class User
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }
#nullable restore
}

 

DbContext

事情により、Blog/Userはそれぞれ別のDbContextとして用意しました。

 

Blog向けのDbContext

BlogのDbSetの他、OnModelCreating()にてFluent APIを使って NOT NULL制約を制御しています。

また、MyLoggerFactoryやOnConfiguringでは、SQLの実行ログをコンソール出力するようにしています。

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

namespace MyApp.GenericInheritance
{
    public class OriginalBlogContext : DbContext
    {
        protected bool _isRequired;
        
        public OriginalBlogContext(DbContextOptions<OriginalBlogContext> 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);
        }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Blog>()
                .Property(blog => blog.Url)
                .IsRequired(_isRequired);
        }
    }
}

 

User向けのDbContext

Blogとはログ出力の部分が重複する一方、DbSetとコンストラクタ・OnModelCreatingが異なります。

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

namespace MyApp.GenericInheritance
{
    public class OriginalUserContext : DbContext
    {
        protected bool _isRequired;
        
        public OriginalUserContext(DbContextOptions<OriginalUserContext> 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);
        }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>()
                .Property(user => user.Name )
                .IsRequired(_isRequired);
        }
    }
}

 

テストコード

テストコード中では、

  • インメモリなSQLiteを使用
  • context.Database.EnsureCreated(); を実行して、エンティティクラスの内容をDBに反映
  • その内容をDbContextでコンソール出力

を行い、コンソールでSQLの実行結果を確認します。

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using MyApp.GenericInheritance;
using Xunit;

namespace MyApp.Test.GenericInheritanceTest
{
    public class OriginalTest
    {
        [InlineData("IsRequiredが true の場合", true)]
        // [InlineData("IsRequiredが false の場合", false)]
        [Theory]
        public void BlogのSQL確認(string title, bool isRequired)
        {
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            try
            {
                var options = new DbContextOptionsBuilder<OriginalBlogContext>()
                    .EnableSensitiveDataLogging()
                    .UseSqlite(connection)
                    .Options;
                using var context = new OriginalBlogContext(options, isRequired);

                context.Database.EnsureCreated();
            }
            finally
            {
                connection.Close();
            }
        }
        
        // [InlineData("IsRequiredが true の場合", true)]
        [InlineData("IsRequiredが false の場合", false)]
        [Theory]
        public void UserのSQL確認(string title, bool isRequired)
        {
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            try
            {
                var options = new DbContextOptionsBuilder<OriginalUserContext>()
                    .EnableSensitiveDataLogging()
                    .UseSqlite(connection)
                    .Options;
                using var context = new OriginalUserContext(options, isRequired);

                context.Database.EnsureCreated();
            }
            finally
            {
                connection.Close();
            }
        }
    }
}

 

実行結果

dotnet test を実行し、SQLを確認します。

なお、オプションとして以下を指定しています。

$ dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.OriginalTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "User" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_User" PRIMARY KEY AUTOINCREMENT,
          "Name" TEXT NULL
      );
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

実装していて気になったこと

Blog/UserのDbContextについて、

  • ログ出力の部分が重複
  • DbSetとコンストラクタ・OnModelCreatingは異なる

という状態です。

そんな中、今後もエンティティクラスごとのDbContextが増えそうな予感がする環境とします。

 

やりたいこと

各エンティティのDbContextについて、異なる部分だけ実装し、コードが見通しやすくしたいです。

一方、テストコードについても重複っぽい部分がありますが、ひとまず置いておきます。

 
そこで、

を行います。

これが、ジェネリッククラスを継承し、型引数付コンストラクタを持つクラスを作成する になります*1

 

作業の流れ

最終的な形だけ出すとどのように変形していったのか分かりづらいので、作業の流れに沿って実装を進めます。

なお、作業途中のものはコンパイルできないと思いますが、説明のために書いていきます。

 

ベースとなるクラスへ重複部分を抽出

重複している部分は

  • ログまわり
  • Fluent APIで動的に指定する、 IsRequired の値

ので、それらをベースクラスに抜き出します。

public class GenericInheritanceBaseContext
    : DbContext
{
    protected bool _isRequired;
    public GenericInheritanceBaseContext(DbContextOptions<OriginalBlogContext> 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);
    }
}

 

コンストラクタ引数の型をジェネリック化 & クラスに型引数を追加

ここでコンストラクタを見ると

public GenericInheritanceBaseContext(DbContextOptions<OriginalBlogContext> options, bool isRequired)

と、引数optionは DbContextOptions<OriginalBlogContext> 型の引数しか受け付けません。このままではUserのDbContextが渡せません。

 
そこで、引数optionの型をジェネリックにするとともに、クラスに型引数を定義します。

// クラスに型引数 "<T>" を追加
public class GenericInheritanceBaseContext<T>
    : DbContext
{
    // コンストラクタの型を "DbContextOptions<OriginalBlogContext>" から型引数 "T" へと変更
    public GenericInheritanceBaseContext(T options, bool isRequired) : base(options)
    {
        _isRequired = isRequired;
    }
}

 

クラスの型引数に対し、制約条件を付与

現在のクラスの型引数は

public class GenericInheritanceBaseContext<T>

となっており、base(options) の中で与えた型に対して操作ができません。

 
そこで、キーワード where を使用して、型引数に制約条件を付け加えます。

今回の型引数は DbContext およびその子クラスであれば良いです。

public class GenericInheritanceBaseContext<T>
    : DbContext
    where T : DbContextOptions  // 制約条件を追加

 
日本語で書くと

public class GenericInheritanceBaseContext<型引数>
    : 親クラス
    where 型引数 : 制約条件となるクラス等

と、 子クラス名、親クラス名、制約条件 の順で定義します。

 

ベースクラスの全体像

ここまででベースとなるクラスができました。全体像はこちら。

public class GenericInheritanceBaseContext<T>
    : DbContext
    where T : DbContextOptions
{
    protected bool _isRequired;
    public GenericInheritanceBaseContext(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);
    }
}

 

BlogのDbContextの現状

親クラスに移動した部分以外が残っていますので、その実装を書いておきます。

なお、今回は後で見返せるよう、今までの OriginalBlogContext は残しておき、別の GenericInheritanceBlogContext として作成しています。

public class GenericInheritanceBlogContext : DbContext
{
    public GenericInheritanceBlogContext(DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
        : base(options)
    {
        _isRequired = isRequired;
    }

    public DbSet<Blog> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(blog => blog.Url)
            .IsRequired(_isRequired);
    }
}

ここで、 OnModelCreating() は子クラスごとに固有なので残しておき、それ以外を修正します。

 

BlogのDbContextで、型引数付きの親クラスを継承するように修正

現在の親クラスは DbContext ですが、これをベースクラス GenericInheritanceBaseContext へと切り替えます。

また、ベースクラスを継承する際、型引数が必要です。今回使う型引数は、コンストラクタで使う DbContextOptions<OriginalBlogContext> です。

つまり

public class GenericInheritanceBlogContext : DbContext

public class GenericInheritanceBlogContext
    : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceBlogContext>>

へと、子クラス : 親クラス<型引数> な定義へと修正します。

 

BlogのDbContextで、コンストラクタの型を修正

ベースクラスのコンストラクタは

public GenericInheritanceBaseContext(T options, bool isRequired) : base(options)

と、型引数で指定したジェネリックを使っています。

そのため、子クラスであるBlogのDbContextでは明示的に型を指定します。

public GenericInheritanceBlogContext(
    DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
    : base(options, isRequired)

 

BlogのDbContext全体像

ここまででBlogのDbContextの修正が終わりましたので、全体像を書いておきます。

using Microsoft.EntityFrameworkCore;

namespace MyApp.GenericInheritance
{
    public class GenericInheritanceBlogContext
        : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceBlogContext>>
    {
        public GenericInheritanceBlogContext(
            DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
            : base(options, isRequired) {}

        public DbSet<Blog> Blogs { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Blog>()
                .Property(blog => blog.Url)
                .IsRequired(_isRequired);
        }
    }
}

 

UserのDbContext

こちらも後で見返せるよう、名前を GenericInheritanceUserContext にしたくらいで、あとはBlogと同じような感じです。

using Microsoft.EntityFrameworkCore;

namespace MyApp.GenericInheritance
{
    public class GenericInheritanceUserContext
        : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceUserContext>>
    {
        public GenericInheritanceUserContext(
            DbContextOptions<GenericInheritanceUserContext> options, bool isRequired)
            : base(options, isRequired) {}

        public DbSet<User> Users { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>()
                .Property(user => user.Name)
                .IsRequired(_isRequired);
        }
    }
}

 

テストコード

こちらも、使用するDbContext名が変わるだけで、あとは同じです。

public class GenericInheritanceTest
{
    [InlineData("IsRequiredが true の場合", true)]
    // [InlineData("IsRequiredが false の場合", false)]
    [Theory]
    public void BlogのSQL確認(string title, bool isRequired)
    {
        var connection = new SqliteConnection("DataSource=:memory:");
        connection.Open();
        try
        {
            var options = new DbContextOptionsBuilder<GenericInheritanceBlogContext>()
                .EnableSensitiveDataLogging()
                .UseSqlite(connection)
                .Options;
            using var context = new GenericInheritanceBlogContext(options, isRequired);

            context.Database.EnsureCreated();
        }
        finally
        {
            connection.Close();
        }
    }
    
    // [InlineData("IsRequiredが true の場合", true)]
    // [InlineData("IsRequiredが false の場合", false)]
    // [Theory]
    public void UserのSQL確認(string title, bool isRequired)
    {
        var connection = new SqliteConnection("DataSource=:memory:");
        connection.Open();
        try
        {
            var options = new DbContextOptionsBuilder<GenericInheritanceUserContext>()
                .EnableSensitiveDataLogging()
                .UseSqlite(connection)
                .Options;
            using var context = new GenericInheritanceUserContext(options, isRequired);

            context.Database.EnsureCreated();
        }
        finally
        {
            connection.Close();
        }
    }
}

 

実行結果の比較

同じ結果が得られ、ジェネリック化できました。

# 修正前
$ dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.OriginalTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );
...
√ MyApp.Test.GenericInheritanceTest.OriginalTest.BlogのSQL確認(title: "IsRequiredが true の場合", isRequired: True) [854ms]


# ジェネリック版
dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.GenericInheritanceTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blogs" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );
...
√ MyApp.Test.GenericInheritanceTest.GenericInheritanceTest.BlogのSQL確認(title: "IsRequiredが true の場合", isRequired: True) [920ms]
...

 

ソースコード

Githubに上げました*2MyApp/GenericInheritanceMyApp.Test/GenericInheritanceTest あたりが今回のディレクトリです。
https://github.com/thinkAmi-sandbox/EntityFramework_Core_Sample

*1:より良いタイトルがあれば教えていただけるとありがたいです

*2:今回のサンプルはEntityFramework Core を使ってるため、リポジトリもEntityFramework Coreのものを使っています

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