しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

Entity Framework Core 5.0 の Many-to-Many サポートを試した

Entity Framework 6 では使えていて、Entity Framework Core になって抜け落ちていた機能として Many-to-Many のサポートがありましたが、5.0 にしてようやく実装が完了したようです。

中間テーブルを意識せずに使えるのが便利だったので、EF Core でも望んでいた機能です。Twitter で書かれているように Daily Build で使えますが、基本的なサポートは preview8 にも入っていました。*1

デモが YouTube での Community Standup でも行われていたので、見ておくと良さそうです。ドキュメントは当然ながらまだ存在しないので、これが唯一の公式情報になる気がします。

Entity Framework Core のようなフル機能の ORM ではなく Dapper などの Micro ORM が好まれることや、そもそも RDB ではなく KVS の方がクラウド上で扱うにはスケーリング面で有利という世界ですが、何だかんだでそれぞれ上手く使い分けるのが正解だと思っています。

実際に Entity Framework Core もかなり進化していて、2.0 / 2.1 で触って諦めたという人がいればキャッチアップしなおすのも良いと思います。

事前準備

話がブレましたが、実際に Many-to-Many サポートを試すための準備を行います。具体的には EF Core の Daily Build をインストールするための設定と、基本となるコードを追加するぐらいです。

今回のコードは全て 5.0.0-rc.1.20431.2 で動作確認しました。既に 6.0 が Daily Build には上がっているので、その点だけは注意が必要になりそうです。

基本となるコードは以下のようなものを用意しました。LocalDB を使っていますが、インストールするのが面倒な場合は SQLite 向けの Provider をインストールすれば良いです。

設定周りで DI は使わずにコンソールアプリでサクッと動かす想定です。

public class AppDbContext : DbContext
{
    public DbSet<Entry> Entries { get; set; }
    public DbSet<Tag> Tags { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ManyToManySample;Trusted_Connection=True;MultipleActiveResultSets=true");
    }
}

public class Entry
{
    public int Id { get; set; }
    public string Title { get; set; }
    public List<Tag> Tags { get; } = new List<Tag>();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Entry> Entries { get; } = new List<Entry>();
}

モデルに関しては特に説明は要らないでしょう。これまでも良く使ってきたエントリに対して、タグを複数付けられるようにするものです。これで Many-to-Many なモデルになります。

中間テーブルを定義せずに使う

まずは EF 6 でよく使われていたと思われる、明示的に中間テーブルを定義せずに、全てを Entity Framework Core に任せるパターンです。必要な中間テーブルは EF Core によって自動生成されるため、RDB 側を意識せずに使えるというメリットがあります。

EF Core 側での定義自体は以下のように非常にシンプルです。ちなみに片方だけ定義すればよいです。

public class AppDbContext : DbContext
{
    public DbSet<Entry> Entries { get; set; }
    public DbSet<Tag> Tags { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ManyToManySample;Trusted_Connection=True;MultipleActiveResultSets=true");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Entry>()
                    .HasMany(x => x.Tags)
                    .WithMany(x => x.Entries);
    }
}

この状態で Add-Migration を使ってマイグレーション用コードを作成して Update-Database を実行すると、以下のように 3 つのテーブルが作成されます。

f:id:shiba-yan:20200901013408p:plain

設定とテーブルの準備が出来たので、実際に項目を追加してみます。この辺りは EF 6 の時とほとんど変わっていないので、特に違和感なく使えるはずです。

class Program
{
    static async Task Main(string[] args)
    {
        using var context = new AppDbContext();

        var entry = new Entry
        {
            Title = "buchizo"
        };

        entry.Tags.AddRange(new[]
        {
            new Tag { Name = "kosmosebi" },
            new Tag { Name = "rd" }
        });

        await context.AddAsync(entry);
        await context.SaveChangesAsync();
    }
}

実行してみると、ちゃんと中間テーブルにデータが追加されていることが確認できます。

f:id:shiba-yan:20200901013711p:plain

最後にクエリを投げてみます。この時 Include を使って明示的に読み込むように指定しないと、空のコレクションになるので注意が必要です。

EF 6 ではデフォルトで Lazy Loading が有効になっていたので、あまり気にしなかった部分だと思います。

class Program
{
    static async Task Main(string[] args)
    {
        using var context = new AppDbContext();

        var entry = await context.Entries
                                 .Include(x => x.Tags)
                                 .FirstAsync(x => x.Id == 1);
    }
}

実行すると Tags もデータが入っていることが確認できます。簡単でしたね。

f:id:shiba-yan:20200901013605p:plain

これが基本パターンになると思いますが、Entity Framework Core ではもう少し拡張できるようになっているので、その辺りも差分で軽くまとめておきます。

中間テーブル名を指定する

EF Core に中間テーブルを任せると名前を指定できませんでしたが、定義中に UsingEntity<T> を呼び出すとテーブル名を指定できるようになります。この指定がちょっとトリッキーな書き方になっています。

素直に中間テーブルのモデルを用意することも出来ますが、EF Core では Dictionary<TKey, TValue> がいろんな部分で使えるようになっているので、これを利用してモデルを定義せずに呼び出します。

public class AppDbContext : DbContext
{
    public DbSet<Entry> Entries { get; set; }
    public DbSet<Tag> Tags { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ManyToManySample;Trusted_Connection=True;MultipleActiveResultSets=true")
                      .UseLazyLoadingProxies();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Entry>()
                    .HasMany(x => x.Tags)
                    .WithMany(x => x.Entries)
                    .UsingEntity<Dictionary<string, object>>(
                        "EntryTagMaps",
                        x => x.HasOne<Tag>().WithMany(),
                        x => x.HasOne<Entry>().WithMany());
    }
}

中々に気持ち悪い書き方ですが、モデルを定義することなくテーブル名を指定できました。

f:id:shiba-yan:20200901020431p:plain

EF Core は Indexer を使ってプロパティを定義せずにモデルを用意することも出来るので、EF 6 とはかなり異なっています。柔軟ではありますが、スキーマが混乱するので多用はしたくない機能です。

中間テーブルを明示的に定義して使う

最後は中間テーブルのモデルを定義して使うパターンです。

EF Core 5.0 以前に Many-to-Many を実現する場合は、大体は以下のドキュメントのように中間テーブルを定義して、それ経由で扱うように書いてきたかと思います。

それをいざ EF Core 5.0 向けに変更するとなると、存在する中間テーブルのスキーマが自動生成されるものとは異なる場合もあると思うので、そういった時には UsingEntity<T> を使って個別に定義します。

以下のコードでは EntryTagMap クラスにあるプロパティそれぞれに紐づけを行っています。

public class EntryTagMap
{
    public int EntryId { get; set; }
    public Entry Entry { get; set; }

    public int TagId { get; set; }
    public Tag Tag { get; set; }
}

public class AppDbContext : DbContext
{
    public DbSet<Entry> Entries { get; set; }
    public DbSet<Tag> Tags { get; set; }
    public DbSet<EntryTagMap> EntryTagMaps { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ManyToManySample;Trusted_Connection=True;MultipleActiveResultSets=true");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Entry>()
                    .HasMany(x => x.Tags)
                    .WithMany(x => x.Entries)
                    .UsingEntity<EntryTagMap>(
                        x => x.HasOne(xs => xs.Tag).WithMany(),
                        x => x.HasOne(xs => xs.Entry).WithMany())
                    .HasKey(x => new { x.EntryId, x.TagId });
    }
}

外部キーが規約と異なっている場合は WithMany の後に HasForeignKey を追加して明示的に指定します。

これで既存の中間テーブルのスキーマに合わせた形で Many-to-Many の設定が行えました。

Lazy Loading と組み合わせて使う

おまけとして Lazy Loading と一緒に使ってみることにします。N+1 問題が起こりやすいので節度を守ってお使いくださいという感じですが、都度 Include での指定が不要なので何だかんだで便利です。

使い方はこれまでのバージョンと変わっていないので、以下のドキュメントやエントリを参照して設定を追加してください。もちろんパッケージは Daily Build のバージョンに合わせる必要があります。

一番シンプルな定義で Lazy Loading を使ってみます。と言っても UseLazyLoadingProxies の呼び出しを追加して、ナビゲーションプロパティを virtual に変更するぐらいで終わりです。

設定抜けは Add-Migration を実行した時にエラーメッセージで教えてくれるので便利でした。

public class AppDbContext : DbContext
{
    public DbSet<Entry> Entries { get; set; }
    public DbSet<Tag> Tags { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ManyToManySample;Trusted_Connection=True;MultipleActiveResultSets=true")
                      .UseLazyLoadingProxies();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Entry>()
                    .HasMany(x => x.Tags)
                    .WithMany(x => x.Entries);
    }
}

public class Entry
{
    public int Id { get; set; }
    public string Title { get; set; }
    public virtual List<Tag> Tags { get; set; } = new List<Tag>();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; }
    public virtual List<Entry> Entries { get; set; } = new List<Entry>();
}

Lazy Loading を有効にしているので、クエリから Include の呼び出しを削除できます。

class Program
{
    static async Task Main(string[] args)
    {
        using var context = new AppDbContext();

        var entry = await context.Entries
                                 .FirstAsync(x => x.Id == 1);

        var tags = entry.Tags;
    }
}

実行すると Tags が遅延読み込みされていることが分かります。ログを追加すると分かりやすいはずです。

f:id:shiba-yan:20200901151210p:plain

非常に地味ですが、この辺りは実際に使っているコードに合わせて有効化するかを決めればよいです。

これで EF 6 から EF Core への移行が更にスムーズに行えるようになったと思います。

*1:ただし色々と未実装部分が残っているので注意