Try .NET Core

.NET Coreを動かした、試した記録を書き残します。

音楽サーバ"Mopidy"のフロントエンドを作る:04 EF-Coreでリレーションとインデックスを作る

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第4回です。
今回は、Entity Framework Coreでのリレーション、インデックス作りです。

Entity Framework Coreのテーブル定義方法

EF-Coreには、テーブルを定義する方法が二つあります。

  1. Entityクラス上でアノテーション(注釈構文)を書く。(=データアノテーション)
  2. DbContextクラス上でモデル生成をハンドルし、定義メソッドを書く。(=Fluent API)

1.のアノテーション構文は見た目にも簡単で、キー定義、カラムのnull許可設定、外部キー定義などが単純な場合はこれだけで完結します。

しかし、キーが複数あったり、インデックスを作りたい場合などは、まだアノテーションで書くことが出来ず、2.のFluent APIを使う必要が出てきます。

今回は二つを併用して、アルバム/アーティスト/ジャンルのリレーションを作り込んでいく過程を追って行きたいと思います。

アルバム/アーティスト/ジャンルの関係は?

ここで一旦、アルバム/アーティスト/ジャンルの3要素の関係を整理します。

ジャンルの中に、複数のアーティスト/アルバムがあるか?
 →どちらも、当然ながら、ありますよね。

アーティストの中に、複数のジャンル/アルバムがあるか?
 →ジャンルは、稀にアルバムごとでジャンルが変わるアーティストがいますね。
 →多くのアーティストはアルバムを複数作るので、アルバムは当然あります。

アルバムの中に、複数のジャンル/アーティストがあるか?
 →ジャンルは、コピレーション盤の場合は十分あり得ますね。
 →アーティストは同じようにコンピレーション盤やトリビュート盤であり得ます。

どうやら、この3つは全て1対多の関係のようです。

テーブルの持ち方は?

ジャンル/アーティスト/アルバムの3つの要素は、それぞれテーブルを作ります。
それらには一意のIDを持たせます。

  • ジャンル:genres
  • アーティスト:artists
  • アルバム:albums

そして、それらの関係性を示すためのリレーション用テーブルとして、以下の3つを作ることにしました。
このテーブルには、2つのIDだけを持つことにします。

  • ジャンル-アーティストの所属関係:genre_artists
    -> ジャンルIDとアーティストIDを持つ
  • ジャンル-アルバムの所属関係:genre_albums
    -> ジャンルIDとアルバムIDを持つ
  • アーティスト-アルバムの所属関係:artist_albums
    -> アーティストIDとアルバムIDを持つ

...うん、めんどくさい!
めんどくさい、けど!
他に、いい方法もないし...。

ということで、これをEF-CoreのEntityコードに落と込んでいきましょう。

ジャンル/アーティスト/アルバムのEntityを書く

この3者のリレーションが出来上がったコミットは、このあたりです。

まずジャンルEntityを、以下のように。
aspCore/Models/Genres/Genre.cs:

[Table("genres")]
[JsonObject(MemberSerialization.OptIn)]
public class Genre
{
    [Key]
    [JsonProperty("Id")]
    public int Id { get; set; }

    [Required]
    [JsonProperty("Name")]
    public string Name { get; set; }

    [Required]
    [JsonProperty("LowerName")]
    public string LowerName { get; set; }

    [Required]
    [JsonProperty("Uri")]
    public string Uri { get; set; }

    [JsonProperty("GenreArtists")]
    public List<GenreArtist> GenreArtists { get; set; }

    [JsonProperty("GenreAlbums")]
    public List<GenreAlbum> GenreAlbums { get; set; }
}

Entityクラス定義に[Table("genres")]アノテーションを入れ、テーブルの名称を指定しています。
Idカラムにある[Key]アノテーションは、主キーを指定するものです。

また、ジャンルの名前と、Mopidy上のジャンルを示すURIは、前回までのRef型で必ず取得できるため、[Required]アノテーションを指定して必須入力とします。

LowerNameカラムは、文字列による絞り込み時に比較しやすいように、英数字を全て小文字化したものをセットする予定です。

そして、末尾に2つのList定義があります。
これは後述する、リレーション用Entityの配列です。

ジャンルに対するアーティスト/アルバムともに1対多のため、どちらもListとします。

なお、[JsonObject(MemberSerialization.OptIn)][JsonProperty("変数名")]アノテーションは、EF-Coreとは無関係のものです。
JSON.Netでインスタンスシリアライズする際の方法と変数名を指定しています。

次に、アーティストEntityの定義がこちら。
aspCore/Models/Artists/Artist.cs:

[Table("artists")]
[JsonObject(MemberSerialization.OptIn)]
public class Artist
{
    [Key]
    [JsonProperty("Id")]
    public int Id { get; set; }

    [Required]
    [JsonProperty("Name")]
    public string Name { get; set; }

    [Required]
    [JsonProperty("LowerName")]
    public string LowerName { get; set; }

    [Required]
    [JsonProperty("Uri")]
    public string Uri { get; set; }

    [JsonProperty("ImageUri")]
    public string ImageUrl { get; set; }

    [JsonProperty("ArtistAlbums")]
    public List<ArtistAlbum> ArtistAlbums { get; set; }

    [JsonProperty("GenreArtists")]
    public List<GenreArtist> GenreArtists { get; set; }
}

アノテーション、リストの意味合いはジャンルEntityと同様です。

Listが二つあることも同じく、アーティストに対するジャンル/アルバムのリレーションEntityが複数存在することを示しています。

アルバムEntityの定義は、前回のものからLowerNameカラム、リレーション用Listが加わりました。
aspCore/Models/Albums/Album.cs:

[Table("albums")]
[JsonObject(MemberSerialization.OptIn)]
public class Album
{
    [Key]
    [JsonProperty("Id")]
    public int Id { get; set; }

    [Required]
    [JsonProperty("Name")]
    public string Name { get; set; }

    [Required]
    [JsonProperty("LowerName")]
    public string LowerName { get; set; }

    [Required]
    [JsonProperty("Uri")]
    public string Uri { get; set; }

    [JsonProperty("Year")]
    public int? Year { get; set; }

    [JsonProperty("ImageUri")]
    public string ImageUri { get; set; }

    [JsonProperty("ArtistAlbums")]
    public List<ArtistAlbum> ArtistAlbums { get; set; }

    [JsonProperty("GenreAlbums")]
    public List<GenreAlbum> GenreAlbums { get; set; }
}

ここまではFluent APIの力を借りず、アノテーション定義だけで済んでいます。

リレーション用Entityを書く

さて次は、上の三者の関係を示すリレーションテーブルの定義です。

まずはジャンル-アーティスト間のリレーションEntity。
aspCore/Models/Relations/GenreArtist.cs:

[Table("genre_artists")]
[JsonObject(MemberSerialization.OptIn)]
public class GenreArtist
{
    [Required]
    [JsonProperty]
    public int GenreId { get; set; }

    [Required]
    [JsonProperty]
    public int ArtistId { get; set; }

    [ForeignKey("GenreId")]
    public Genre Genre { get; set; }

    [ForeignKey("ArtistId")]
    public Artist Artist { get; set; }
}

ここでは、主キーを示す[Key]アノテーションを使いません。

正確には、使うことが出来ません。
なぜかというと、このテーブルではGenreIdArtistIdの二つを主キーとしたいからです。

主キーを複数もつとき、[Key]アノテーションを使うことが出来ないんですね。
そこで主キーは、後述するFluent APIで定義するようにします。
※なお、EF-Coreでは主キーの無いテーブルを扱うことが出来ません。※

ということで、ここではGenreIdArtistIdにひとまず[Required]アノテーションだけを付けています。

また、新しいアノテーション[ForeignKey("変数名")]が出てきますね。
これは、外部キーとして使う変数名はコレですよ、という指定です。

[ForeignKey("GenreId")]
public Genre Genre { get; set; }

とすることで、外部キーにGenreIdを使ってGenreエンティティを結合しといてね、という指示が出来るわけです。

ジャンル-アルバムのリレーションEntity、アーティスト-アルバムのリレーションEntityも、中身は似たり寄ったりです。
aspCore/Models/Relations/GenreAlbum.cs:

[Table("genre_albums")]
[JsonObject(MemberSerialization.OptIn)]
public class GenreAlbum
{
    [Required]
    [JsonProperty]
    public int GenreId { get; set; }

    [Required]
    [JsonProperty]
    public int AlbumId { get; set; }

    [ForeignKey("GenreId")]
    public Genre Genre { get; set; }

    [ForeignKey("AlbumId")]
    public Album Album { get; set; }
}

aspCore/Models/Relations/ArtistAlbum.cs:

[Table("artist_albums")]
[JsonObject(MemberSerialization.OptIn)]
public class ArtistAlbum
{
    [Required]
    [JsonProperty]
    public int ArtistId { get; set; }

    [Required]
    [JsonProperty]
    public int AlbumId { get; set; }

    [ForeignKey("ArtistId")]
    public Artist Artist { get; set; }

    [ForeignKey("AlbumId")]
    public Album Album { get; set; }
}

Entityクラスの定義は、ひとまずここまでで終わります。
しかしまだ、リレーションEntityの主キー設定とインデックス付与を、Fluent APIで書くお仕事が残っています。

Fluent APIを書く

さて、テーブル作りまでの最後のもうひとつ。Fluent APIです。
Fluent APIは、DbContextを継承したクラス‘Dbc‘で書きます。

aspCore/Models/Dbc.cs

public class Dbc: DbContext
{
    // --- 一部割愛 ---

    public DbSet<Album> Albums { get; set; }
    public DbSet<Genre> Genres { get; set; }
    public DbSet<Artist> Artists { get; set; }
    public DbSet<ArtistAlbum> ArtistAlbums { get; set; }
    public DbSet<GenreAlbum> GenreAlbums { get; set; }
    public DbSet<GenreArtist> GenreArtists { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Album>()
            .HasIndex(e => e.Uri);
        modelBuilder.Entity<Genre>()
            .HasIndex(e => e.Uri);
        modelBuilder.Entity<Artist>()
            .HasIndex(e => e.Uri);
        modelBuilder.Entity<ArtistAlbum>()
            .HasKey(e => new { e.ArtistId, e.AlbumId });
        modelBuilder.Entity<GenreAlbum>()
            .HasKey(e => new { e.GenreId, e.AlbumId });
        modelBuilder.Entity<GenreArtist>()
            .HasKey(e => new { e.GenreId, e.ArtistId });
    }
}

まず先頭部分、前回はDbSet<Album>だけでしたが、

  • DbSet<Genres>
  • DbSet<Artists>
  • DbSet<ArtistAlbums>
  • DbSet<GenreAlbums>
  • DbSet<GenreArtists>

とテーブルの数だけ増えています。
EF-Coreに「コレはテーブルだかんな!」と教えるためのものですね。

そして肝心のFluent API。これは、OnModelCreatingメソッドをoverrideして、引数で受け取ったmodelBuilderインスタンスを介して書きます。

Album, Genre, ArtistのEntityには、Uriカラムにインデックスが要るかも、ということで

modelBuilder.Entity<Album>()
    .HasIndex(e => e.Uri);

のように、HasIndexメソッドを使います。

そしてリレーションEntityの二つの主キーは下記のように、HasKeyメソッドを使います。

modelBuilder.Entity<ArtistAlbum>()
    .HasKey(e => new { e.ArtistId, e.AlbumId });

これで、テーブル生成のための準備が終わりです!

テーブルを生成する

いよいよ、テーブルの生成です!
と言っても、やりかたは前回と同じです。

プロジェクト初期はテーブル構造を試行錯誤しますので、以前作ったマイグレーションとデータベースファイルは一旦削除してしまいます。
そして、パッケージマネージャーコンソールAdd-Migrationします。

PM> add-migration CreateTables

f:id:try_dot_net_core:20190803144218j:plain 前回と違って、albumsテーブルの後にartistsテーブルの生成コードが出来てます。
よしよし。

続いて、DBの更新です。

PM > update-database

f:id:try_dot_net_core:20190803144418j:plain
正常に終わりました。

さて、テーブルは出来てますかね?
f:id:try_dot_net_core:20190803144606j:plain
おーし、Entity定義した6つ分のテーブルが出来てますね!
なお_EFMigrationsHistoryテーブルは、EF-Coreがマイグレーション履歴を管理するために生成するテーブルです。

これでデータさえ入れば、LINQ to SQLのクエリでリレーションを追って関連Entityが取得できるはず。

var withArtists = Dbc.Genres
    .Include(e => e.GenreArtists)
    .ThenInclude(e2 => e2.Artist)
    .Where(e => e.Id == 1)
    .ToArray();

こんな感じの構文ですかね?

いよいよ、第一の目的に近づいてきました!