vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

Entity Framework Core で色々な SQL を投げてみる 3

はじめに

ここまで SQL ゼロからはじめるデータベース操作 を参考に、色々な SELECT 文を投げてみました。

本当はこの後 CROSS JOIN やウインドウ関数があるのですが、どうやら標準では用意されていない様子。

データを分析するウインドウ関数については Raw SQL で実行するにせよ C# の処理として実行するにせよ、追いかけてみたいとは思います。

が、まずは CRUD の残りの Create(Insert)、 Update 、 Delete を試してみたいと思います。

EntityState

Insert 、 Update 、 Delete はどれも DB の値を変更する処理なわけですが、これが実行されるには次のステップを踏むことになります。

  1. EntityState の値を変更する
  2. SaveChangesAsync (SaveChanges) で 1. を反映する

1.については直接そのようなコードを書くのではなく、 Add (AddRange) 、 Update (UpdateRange) 、 Remove (RemoveRange) を実行したときに自動で変更されます。
(現在の EntityState の内容を直接見たり変更したりすることはできないようです)

EntityState の種類はこの辺りを参照。

この後見ていきますが、レコードの追加や削除は、 Add 、 Remove と List に対して行うのとほぼ同じ処理でできるのですが、 SaveChangesAsync を実行しないと DB には反映されないため、注意が必要です。

実行する

ともあれ、まずはあれこれ実行してみますよ。

[INSERT] レコードの追加

レコードの追加から。

コード

var newBook = new Book
{
    BookId = 996,
    AuthorId = 0,
    Name = "hello"
};
_context.Add(newBook);

await _context.SaveChangesAsync();

発行される SQL

INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
     VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);

[UPDATE] レコードの更新

コード

Book addedBook = await _context.Book.FirstAsync(b => b.BookId == 996);
addedBook.Genre = "Comic";
_context.Update(addedBook);

await _context.SaveChangesAsync();

発行される SQL

SELECT b."BookId", b."AuthorId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."BookId" = 996
      LIMIT 1

UPDATE "Book" SET "AuthorId" = @p0, "Genre" = @p1, "Name" = @p2, "Price" = @p3, "ReleaseDate" = @p4, "Thumbnail" = @p5
     WHERE "BookId" = @p6;

[DELETE] レコードの削除

コード

Book addedBook = await _context.Book.FirstAsync(b => b.BookId == 996);

_context.Remove(addedBook);

await _context.SaveChangesAsync();

発行される SQL

SELECT b."BookId", b."AuthorId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."BookId" = 996
      LIMIT 1

DELETE FROM "Book"
WHERE "BookId" = @p0;

Key が無い場合

UPDATE 、 DELETE はそれぞれ主キーである BookId で対象のレコードを検索していました。

では主キーが無い場合はどうなるのか。

というと、 DbContext (EfCoreNpgsqlSampleContext) の初期化時に InvalidOperationException が発生します。

理由は主キーが見つからないから。
怖い。。。

テーブルで主キーが設定されていることがわかっている場合は問題ありませんが、そうでない場合は Model クラスの主キーとなる要素に [Key] 属性を持たせる必要があります。

Author2.cs ( テーブルでは主キーを未設定)
using System.ComponentModel.DataAnnotations;

namespace EfCoreNpgsqlSample.Models
{
    public class Author2
    {
        // 属性を外すとエラー
        [Key]
        public int AuthorId { get; set; }
        public string Name { get; set; }
    }
}

もちろんテーブルに主キーが設定されている場合も [Key] 属性を付けても問題ないため、主キーの設定有無によらず必ず付けてあげるのが良い気がします。

組み合わせ

先ほど触れた通り、 CREATE や UPDATE は SaveChangesAsync が実行されて初めて実行されます。

ということは、 EntityState を変更する処理を複数実行し、まとめて反映することも可能なのでは?

ということでやってみました。

AddRange でまとめて登録

組み合わせではありませんが、 Add には、 AddRange のような複数データをまとめて追加するメソッドが用意されています ( Update 、 Remove も同様)。

これを使った場合、要素数SQL が発行されるのでしょうか。
それとも、別の方法でデータが追加されるのでしょうか。

コード

List< Book> books = new List< Book>
{
    new Book
    {
        BookId = 996,
        AuthorId = 0,
        Name = "hello"
    },
    new Book
    {
        BookId = 995,
        AuthorId = 0,
        Name = "hello1"
    },
};
await _context.AddRangeAsync(books);
await _context.SaveChangesAsync();

発行される SQL

INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);
INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p7, @p8, @p9, @p10, @p11, @p12, @p13);

シンプルに 2 回 INSERT が実行されました。

Add を複数回実行してから反映する

コード

_context.Add(newBook);
_context.Add(newBook2);

await _context.SaveChangesAsync();

発行される SQL

INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);
INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p7, @p8, @p9, @p10, @p11, @p12, @p13);

結果としては AddRange と同じ内容となりました。

Add したデータを反映前に Remove する

コード

_context.Add(newBook);

_context.Remove(newBook);

await _context.SaveChangesAsync();

INSERT も DELETE も実行はされませんでした。

Add したデータを反映前に Update する

コード

_context.Add(newBook);

_context.Update(newBook);

await _context.SaveChangesAsync();

似たような発想で DB 反映前に Update を実行してみたところ、 UPDATE 対象のレコードが見つからないとエラーになりました。

SQL 実行時に(実行順ではない)優先順位が付けられており、 CREATE より UPDATE が先に実行されたようです。

これは主キーの重複を避けるためだと思われます。

トランザクション

DB の値を変更する処理では、処理の途中でエラーが発生した場合などに、変更が中途半端に反映されたりしないようトランザクションを使ってDBへの反映をコントロールします。

Entity Framework Core では、 DbContext が持つ Database でトランザクションがコントロールされています。

using (IDbContextTransaction transaction = _context.Database.BeginTransaction())
{
    try
    {
        await _context.AddRangeAsync(books);
        await _context.SaveChangesAsync();
    
        // DB に変更が反映される.
        transaction.Commit();
    }
    catch (Exception e)
    {
        // BeginTransaction より前の状態に巻き戻される.
        transaction.Rollback();
    }
}

この例だとほぼトランザクションの意味がないわけですが、複数のテーブルの値を更新する場合などに効力を発揮します。

Commit しない場合

Commit しない場合、 SQL 自体は発行されるのでしょうか。

コード

using (IDbContextTransaction transaction = _context.Database.BeginTransaction())
{
    try
    {
        await _context.AddRangeAsync(books);
        await _context.SaveChangesAsync();
    
        // DB に変更が反映される.
        //transaction.Commit();
    }
    catch (Exception e)
    {
        // BeginTransaction より前の状態に巻き戻される.
        transaction.Rollback();
    }
}

発行される SQL

INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);
INSERT INTO "Book" ("BookId", "AuthorId", "Genre", "Name", "Price", "ReleaseDate", "Thumbnail")
VALUES (@p7, @p8, @p9, @p10, @p11, @p12, @p13);

SQL は発行されました。が、 DB には値が追加されませんでした。

おわりに

だいぶ端折りはしたものの、 Entity Framework Core を使った簡単な DB 操作は何となく触れられたんじゃないかな~、と思います。

Linq 的に書けたり、 BeginTransaction が using でスコープ設定できるところなど、 C# 的に DB 操作が書ける、というのはやはり便利ですね。

次はいよいよ DbContext …と言いたいところなのですが、諸事情により ASP.NET Core のログイン・ログアウトを色々試してみることにする予定です。

Entity Framework Core で色々な SQL を投げてみる 2

はじめに

SELECT 文の続きです。

今回も SQL ゼロからはじめるデータベース操作 を参考に、色々試してみますよ。

SELECT 2

コード

await _context.Book.Where(b => b.Name.StartsWith("Entity")).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Name" LIKE 'Entity%'

[LIKE]中間一致で検索する

コード

await _context.Book.Where(b => b.Name.Contains("Framework")).ToListAsync();

発行される SQL

SELECT b."BookId", b."Name", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE STRPOS(b."Name", 'Framework') > 0

むむむ。発行したかった SQL は「 WHERE b."Genre" LIKE '%Entity%' 」だったのですが。。。

あくまで %QUERY% にこだわるなら、もう少し別の方法もあるかもしれませんね。

[参照]

コード

await _context.Book.Where(b => b.Name.EndsWith("Action")).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE RIGHT(b."Name", LENGTH('Action')) = 'Action'

むぅ。。。これも違う。

結果が正しいので良い。。。のかな?

正規表現で検索する

コード

System.Text.RegularExpressions.Regex regex1 = new System.Text.RegularExpressions.Regex("Action$");
await _context.Book.Where(b => regex1.IsMatch(b.Name)).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b

Where ごと消えた/(^o^)\

この辺は発行された SQL の内容で確認するのが難しそうですねぇ。。。

warn: Microsoft.EntityFrameworkCore.Query[20500]
      The LINQ expression 'where __regex1_0.IsMatch([b].Name)' could not be translated and will be evaluated locally.

[BETWEEN] 価格帯で検索する

コード

await _context.Book.Where(b => b.Price >= 500m && b.Price <= 5000m).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE (b."Price" >= 500.0) AND (b."Price" <= 5000.0)

ですよね~、という結果ですが、どうやら Linq に BETWEEN 的なものはなさそうです。

[WHERE IN] 価格リストに合致するレコードを検索する

コード

await _context.Book.Where(b => new []{10m, 500m, 7000m}.Contains(b.Price)).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Price" IN (10.0, 500.0, 7000.0)

なお、下記のように配列部分を変更しても同じ結果となりました。

var prices = new[] {10m, 500m, 7000m};
await _context.Book.Where(b => prices.Contains(b.Price)).ToListAsync();

[EXISTS] 価格が 5000 円以上のレコードが存在するか

コード

_context.Book.Any(b => b.Price >= 5000m);

発行される SQL

 SELECT CASE
          WHEN EXISTS (
              SELECT 1
              FROM "Book" AS b
              WHERE b."Price" >= 5000.0)
          THEN TRUE::bool ELSE FALSE::bool
      END

おっと。ここで CASE が登場しましたね。

THEN 以降は、 C#SQL で型を合わせるためなのでしょうか。

[CASE] CASE 式を使う

コード

_context.Book.Select(b => (b.Price >= 5000m)? 1:0 ).ToList();

発行される SQL

SELECT CASE
          WHEN b."Price" >= 5000.0
          THEN 1 ELSE 0
      END
      FROM "Book" AS b

Linq には Case が無いようです。

そのため Entity Framework Core でも使えない。。。と思いきや、参考演算子にすると CASE 式に変換されれるようです。

なお上記サンプルの雑さは見ないことにしてくださいorz

[UNION ALL] 2 つのテーブルの検索結果を足し合わせる(重複あり)

準備

Book テーブルと同じレイアウトで Book2 というテーブルを作ります。

Book2
CREATE TABLE "Book2" (
"BookId" integer PRIMARY KEY,
"Genre" text,
"Name" text,
"Price" numeric,
"ReleaseDate" timestamp without time zone,
"Thumbnail" bytea)

ここにいくつかのレコードは Book と重複する内容で、雑にレコードを追加します。

C# 側でも、 Model クラスと Context クラスへの追加を行います。

Book2.cs
using System;

namespace EfCoreNpgsqlSample.Models
{
    public class Book2
    {
        [Key]
        public int BookId { get; set; }
        public string Name { get; set; }
        public DateTime ReleaseDate { get; set; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
        public byte[] Thumbnail { get; set; }
    }
}
  • BookId に [Key] がついているのは、 CREATE TABLE 時に BookId を PRIMARY KEY として登録したためです(外すとエラーになります)。
EfCoreNpgsqlSampleContext.cs
using Microsoft.EntityFrameworkCore;

namespace EfCoreNpgsqlSample.Models
{
    public class EfCoreNpgsqlSampleContext: DbContext
    {
        public EfCoreNpgsqlSampleContext(DbContextOptions< EfCoreNpgsqlSampleContext> options)
            : base(options)
        {            
        }  
        public DbSet< Book> Book { get; set; }

        public DbSet< Book2> Book2 { get; set; }

    }
}

で、 Book 、 Book2 への検索結果を足し合わせたい、というのが目標です。

SQL はこちら。

SELECT "BookId", "Genre", "Name", "Price", "ReleaseDate"
FROM "Book"
UNION ALL
SELECT "BookId", "Genre", "Name", "Price", "ReleaseDate"
FROM "Book2"

SQL はこれで良いのですが、 C# では Book と Book2 が別のクラスとして扱われるので、これをそのまま実行することができません。

Book2 の結果を受け取った後、 Book に変換したあと Concat で結合します。

コード

IQueryable< Book> books1 = _context.Book
    .Where(b => b.Name != null);
IQueryable< Book> books2 = _context.Book2
    .Where(b => b.Name != null)
    .Select(b => new Book
    {
        BookId = b.BookId,
        Name = b.Name,
        Genre = b.Genre,
        Price = b.Price,
        ReleaseDate = b.ReleaseDate
    });
await books1.Concat(books2).ToListAsync();

Book テーブルと Book2 テーブルに対してそれぞれ SELECT を実行しているため、発行される SQL も 2 つに分かれています。

発行される SQL

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (7ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Name" IS NOT NULL
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT b0."BookId", b0."Name", b0."Genre", b0."Price", b0."ReleaseDate"
      FROM "Book2" AS b0
      WHERE b0."Name" IS NOT NULL

[UNION] 2 つのテーブルの検索結果を足し合わせる(重複なし)

Concat で結合するだけだと、重複するデータがある場合もそのまま出力されます。

重複するデータを取り除く方法として Distinct があります。

が、そのままだと Book テーブルと Book2 テーブルに重複があっても別物として扱われます。

ということで、 Book クラスの Equals をオーバーライドします。

Book.cs

using System;
using System.ComponentModel.DataAnnotations;

namespace EfCoreNpgsqlSample.Models
{
    public class Book
    {
        [Key]
        public int BookId { get; set; }
        public string Name { get; set; }
        [DataType(DataType.Date)]
        public DateTime ReleaseDate { get; set; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
        public byte[] Thumbnail { get; set; }

        public override bool Equals(object obj)
        {
            var compareBook = obj as Book;
            if (compareBook == null)
            {
                return false;
            }
            return compareBook.BookId == BookId;
        }
        public override int GetHashCode()
        {
            return BookId;
        }
    }
}

GetHashCode で、戻り値が static じゃないよ!と警告が出たりしていますが、今回は見なかったことに。。。(良い子はマネしない)

また、今回は BookId のみで比較していますが、実際の用途的には Name や Genre で比較したほうが良さそうです。

なお SQL の UNION では「 SELECT "BookId", "Genre", "Name", "Price", "ReleaseDate" ~」なら BookId 、 Genre 、 Name 、 Price 、 ReleaseDate の全部が一致しているかどうかを見ているようです。

あとは Distinct で重複データが取り除けます。

await books1.Concat(books2).Distinct().ToListAsync();

[INTERSECT] 2 つのテーブルの共通部分を取り出す

Linq にもそのままズバリ Intersect が存在するため、これを使えば実現できます。

コード

await books1.Intersect(books2).ToListAsync();

先ほどと同じく SQL には反映されないため、発行された SQL は省略します。

[EXCEPT] 2 つのテーブルで共通しないレコードのみを取り出す

こちらも Except が存在するため楽に実現できますね。

コード

await books1.Except(books2).ToListAsync();

注意点?としては、上記の例では Book テーブルの持つレコードがベースとなるため、 Book1 にのみ共通しないレコードが存在する場合、 0 件で返ってきます。

[INNER JOIN] 複数のテーブルを参照する(内部結合)

準備

Author テーブルを作り、 Book テーブルに AuthorId 列を追加します。

Author
CREATE TABLE "Author" (
"AuthorId" integer PRIMARY KEY,
"Name" text)

Book

ALTER TABLE "Book" 
ADD COLUMN "AuthorId" integer

C# 側でも、 Model クラスと Context クラスへの追加を行います。

Author.cs
using System.ComponentModel.DataAnnotations;

namespace EfCoreNpgsqlSample.Models
{
    public class Author
    {
        [Key]
        public int AuthorId { get; set; }
        public string Name { get; set; }
    }
}
Book.cs
using System;
using System.ComponentModel.DataAnnotations;

namespace EfCoreNpgsqlSample.Models
{
    public class Book
    {
        [Key]
        public int BookId { get; set; }

        public int AuthorId { get; set; }
        ~省略~
    }
}
EfCoreNpgsqlSampleContext.cs
using Microsoft.EntityFrameworkCore;

namespace EfCoreNpgsqlSample.Models
{
    public class EfCoreNpgsqlSampleContext: DbContext
    {
        public EfCoreNpgsqlSampleContext(DbContextOptions options)
            : base(options)
        {
            
        }
        
        public DbSet< Book> Book { get; set; }
        public DbSet< Book2> Book2 { get; set; }
        public DbSet< Author> Author { get; set; }
    }
}

Linq には Join が用意されているため、これを使います。

コード

// 戻り値の型は IQueryable< anonymous type>
from book in _context.Book
        join author in _context.Author
            on book.AuthorId equals author.AuthorId
        select new
        {
            BookId = book.BookId,
            AuthorId = author.AuthorId,
            BookName = book.Name,
            AuthorName = author.Name,
        };

発行される SQL

SELECT book."BookId", author."AuthorId", book."Name" AS "BookName", author."Name" AS "AuthorName"
      FROM "Book" AS book
      INNER JOIN "Author" AS author ON book."AuthorId" = author."AuthorId"

無名クラスとして結合した値を受け取ることができるため、一時的に使うだけならこの形が便利ですね。

なお唐突にクエリ式になったのは、 Join についてはこっちの方が理解しやすそうだったためです。

[LEFT OUTER JOIN] 複数のテーブルを参照する(外部結合)

※ 2019/02/26 23:30 更新

Book テーブルに Author テーブルに存在しない AuthorId を設定した場合もエラーが起きないよう修正しました。

コード

var results = from book in _context.Book
                join author in _context.Author
                    on book.AuthorId equals author.AuthorId into gj
                from ba in gj.DefaultIfEmpty()
                select new
                {
                    BookId = book.BookId,
                    AuthorId = book.AuthorId,
                    BookName = book.Name,
                    AuthorName = (ba == null)? "": ba.Name,
                };

発行される SQL

SELECT book."BookId", book."AuthorId", book."Name" AS "BookName", CASE
          WHEN author."AuthorId" IS NULL
          THEN '' ELSE author."Name"
      END AS "AuthorName"
      FROM "Book" AS book
      LEFT JOIN "Author" AS author ON book."AuthorId" = author."AuthorId"

SQL に反映されていない。。。

が、これで Book テーブルにあって、 Author テーブルに登録されていない AuthorId を持つレコードが結果に出力されるようになりました。

またまた長くなってきたので切ります。

Entity Framework Core で色々な SQL を投げてみる 1

はじめに

さて、 PostgreSQL に接続できたことだし、 DbContext を追うぞ~と思ったのですが、あまりに知識が雑すぎたためどこから手を付けていいかもわからない状態に。。。orz

引き続き追いかけてはいくのですが、まずは下記の書籍を参考に、 Entity Framework Core で色々な SQL 文を発行してみたいと思います。

まずは SELECT から。

メモ

とその前に小ネタをメモっておきます。

Model クラスとテーブルに差異がある場合

  • Model クラスに DB のテーブルに無い値(プロパティ)がある場合 → そのプロパティを使っている、使っていないにかかわらず Exception が発生します。
  • DB のテーブルには存在するが、 Model クラスにプロパティが無い値(カラム)がある場合 → 問題なし。ただ値が取れないだけ。

また、 テーブルのカラム名と Model クラスのプロパティの名前が異なる場合、 [ Column("")] で指定することができます。

SELECT

全件取得

Book テーブルのデータを全件取得します。

コード

await _context.Book.ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b

コンソールに出力されるのが便利ですね。

カラムを指定して検索

カラム BookId のみを指定して取得してみます。

コード

await _context.Book.Select(b => b.BookId).ToListAsync();

発行される SQL

SELECT b."BookId"
      FROM "Book" AS b

[WHERE] 価格が 3000 円以上のレコードを取得する

コード

await _context.Book.Where(b => b.Price >= 3000).ToListAsync();

発行される SQL

 SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Price" >= 3000.0

[LIMIT] 条件に合致するレコードを一件だけ取得する

コード

_context.Book.FirstOrDefault(b => b.Price >= 3000);

発行される SQL

 SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Price" >= 3000.0
      LIMIT 1

[LIMIT] 条件に合致するレコードを三件(二件以上)取得する

コード

await _context.Book.Where(b => b.Price >= 3000).Take(3).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE b."Price" >= 3000.0
      LIMIT @__p_0

結果としては FirstOrDefault と同じように LIMIT が使われるのですが、パラメーターが不思議な値になっています。
(なお Take の引数を変えてもコンソール出力の内容は変わりませんでした)

コンソール出力後に置き換えられるのでしょうか。

※なお TakeLast はサポート外らしく、使用すると Exception が発生します。

[DISTINCT] ジャンルが重複するデータを除く

コード

await _context.Book.Select(b => b.Genre).Distinct().ToListAsync();

発行される SQL

SELECT DISTINCT b."Genre"
      FROM "Book" AS b

[演算子] SELECT で演算子を使う

コード

await _context.Book.Select(b => b.Price * 1.08m).ToListAsync();

発行される SQL

SELECT b."Price" * 1.08
      FROM "Book" AS b

[演算子] WHERE で演算子を使う

コード

await _context.Book.Where(b => (b.Price * 1.08m) >= 3000.0m).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      WHERE (b."Price" * 1.08) >= 3000.0

[COUNT] レコード数をカウントする

コード

await _context.Book.CountAsync();

発行される SQL

SELECT COUNT(*)::INT
      FROM "Book" AS b

[COUNT] ジャンルが NULL でないレコード数をカウントする

コード

await _context.Book.CountAsync(b => b.Genre != null);

発行される SQL

SELECT COUNT(*)::INT
      FROM "Book" AS b
      WHERE b."Genre" IS NOT NULL

「 SELECT COUNT("Genre") FROM "Book" 」とできると、同様のことが WHERE 無しでできるのですが、少なくとも CountAsync() を使う場合、「 COUNT(*)::INT 」となってしまうようです。

[SUM] 価格の合計を求める

コード

await _context.Book.SumAsync(b => b.Price);

発行される SQL

SELECT SUM(b."Price")
      FROM "Book" AS b

[AVG] 価格の平均を求める

コード

await _context.Book.AverageAsync(b => b.Price);

発行される SQL

SELECT AVG(b."Price")
      FROM "Book" AS b

[MAX] 価格の最大値を求める

コード

await _context.Book.MaxAsync(b => b.Price);

発行される SQL

SELECT MAX(b."Price")
      FROM "Book" AS b

[MIN] 価格の最小値を求める

コード

await _context.Book.MinAsync(b => b.Price);

発行される SQL

SELECT MIN(b."Price")
      FROM "Book" AS b

[GROUP BY???] ジャンルでグループ分けする

コード

await _context.Book.GroupBy(b => b.Genre).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      ORDER BY b."Genre"

ORDER BY ???

発行したかったのは

SELECT "Genre" FROM "Book" GROUP BY "Genre"

です。

結果としてはどちらもジャンルでグループ分けできているようなのですが。。。

なお、当然ながら発行された SQL の内容を PgAdmin で実行すると、ジャンルでソートされた結果が表示されるだけです。

と思ったら、コンソールに下記の警告が出力されていました。

warn: Microsoft.EntityFrameworkCore.Query[20500]
      The LINQ expression 'GroupBy([b].Genre, [b])' could not be translated and will be evaluated locally.

どうやら、上記の SQL が発行される前に C# 側で何か処理を行っているようです。

同じようなことを Issue で挙げている方もいたようですが。。。

https://github.com/aspnet/EntityFrameworkCore/issues/9964

これについては、次回以降でもう少し見てみたいと思います(挫折しなければ)。

[HAVING] グループ化したレコードをフィルタリングする

コード

await _context.Book.GroupBy(b => b.Genre)
                .Where(b => string.IsNullOrEmpty(b.Key) == false)
               .ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      ORDER BY b."Genre"

あれ?発行される SQL がさっきと変わってない(´・ω・`)

結局 GroupBy がそのまま変換されないため、そこに対する Where も変換はされないようです(結果には反映されますが)。

ちなみに Where についても警告が出力されています。

warn: Microsoft.EntityFrameworkCore.Query[20500]
      The LINQ expression 'GroupBy([b].Genre, [b])' could not be translated and will be evaluated locally.
warn: Microsoft.EntityFrameworkCore.Query[20500]
      The LINQ expression 'where (IsNullOrEmpty([b].Key) == False)' could not be translated and will be evaluated locally.

[参照]

[ORDER BY] レコードをリリース日でソートする(昇順)

コード

await _context.Book.OrderBy(b => b.ReleaseDate).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      ORDER BY b."ReleaseDate"

GroupBy でも(なぜか) ORDER BY が発行されていたので違いがよくわからない感じになっていますが(苦笑)、割とそのまま書けますね。

[ORDER BY] レコードをリリース日でソートする(降順)

コード

await _context.Book.OrderByDescending(b => b.ReleaseDate).ToListAsync();

発行される SQL

SELECT b."BookId", b."Genre", b."Name", b."Price", b."ReleaseDate", b."Thumbnail"
      FROM "Book" AS b
      ORDER BY b."ReleaseDate" DESC

長くなってきたのでいったん切ります。

次回は SQL ゼロからはじめるデータベース操作 の六章ぐらいから。

【ASP.NET Core】【Entity Framework Core】PostgreSQL に接続してみる

はじめに

Entity Framework Core の謎を追うシリーズの途中ですが、ここまで使ってきた SQLite ではなく、 PostgreSQL を使うことにしました。

PostgreSQL に興味があるとか、 PgAdmin4 を使ってみたいとかはもちろんあるのですが、唐突に話が変わる場合は。。。お察しくださいw

準備

今回 PostgreSQL は Docker のものを使用しました。

https://hub.docker.com/_/postgres

Description の内容を元に設定したのですが、この辺りはもう少しちゃんと内容を理解してから取り上げたいと思います。
(今はとりあえず動いてる状態なので)

PgAdmin はローカルにインストールしたものを使用しています。

https://www.pgadmin.org/

接続する

基本的な内容は SQLite を使った時と同じです。

NuGet パッケージとして、 SQLite の代わりに Npgsql.EntityFrameworkCore.PostgreSQL をインストールしました。

https://github.com/npgsql/Npgsql.EntityFrameworkCore.PostgreSQL/blob/dev/doc/index.md

Connection string などの設定は、 SQLite と同じく Startup でできる他、上記を見ると DbContext クラスに書いているようです。

Startup.cs に書く場合

EfCoreNpgsqlSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace EfCoreNpgsqlSample.Models
{
    public class EfCoreNpgsqlSampleContext: DbContext
    {
        public EfCoreNpgsqlSampleContext(DbContextOptions< EfCoreNpgsqlSampleContext> options)
            : base(options)
        {   
        }        
        public DbSet< Book> Book { get; set; }
    }
}

Startup.cs

        ~省略~
        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure< CookiePolicyOptions>(options =>
            {
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });

            services.AddDbContext< EfCoreNpgsqlSampleContext>(options =>
                options.UseNpgsql("Host=localhost;Database=DbName;Username=postgres;Password=XXXX"));

            services.AddMvc();
        }
        ~省略~

DbContext に書く場合

EfCoreNpgsqlSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace EfCoreNpgsqlSample.Models
{
    public class EfCoreNpgsqlSampleContext: DbContext
    {
        public EfCoreNpgsqlSampleContext(DbContextOptions< EfCoreNpgsqlSampleContext> options)
            : base(options)
        {
            
        }
        
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            => optionsBuilder.UseNpgsql("Host=localhost;Database=DbName;Username=postgres;Password=XXXX");
            
        public DbSet< Book> Book { get; set; }
    }
}

Startup.cs

        ~省略~
        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure< CookiePolicyOptions>(options =>
            {
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });

            services.AddEntityFrameworkNpgsql()
                .AddDbContext< EfCoreNpgsqlSampleContext>()
                .BuildServiceProvider();

            services.AddMvc();
        }
        ~省略~      

後者は Connection string が実際にそれを必要とする DbContext に書ける、という利点はあるものの、二か所に分割して書くことになるため面倒にも感じましたが、他にも利点はあるのでしょうか。

ともあれ、これで PostgreSQL に接続し、データの入出力ができるようになりました。

NuGet パッケージの差し替えと数か所のコードの変更だけで DB が切り替えられる、というのはやはり便利ですね。

Entity Framework Core のスキャフォールド・マイグレーションで生成されたものを見たい

はじめに

前回、半年間溜め込んでいた内容をようやくブログとして書くことができて、スッキリしている今日この頃ですw

今回は生成されたファイルを覗いてみることにします。

マイグレーションはスキャフォールドを必要とするか

前回はスキャフォールドで View 、 Controller を生成したあとマイグレーションを実行していました。

ただ、 Controller はともかく View は不要( Web API とか)な場合、わざわざスキャフォールドしなくても Controller だけ作れば良いような気もします。

ということで確認したところ、特にスキャフォールドを実行していなくてもマイグレーションできました。

ということで、状況に合わせて使い分けるのが良さそうですね。

生成した・されたファイルを見てみる

さて、前回 Empty テンプレートの ASP.NET Core プロジェクトに追加した、またはスキャフォールド・マイグレーションで生成されたファイルを見てみることにします。

Controllers
  L MoviesController.cs
Migrations
  L 20190214222750_AddMovie.cs
  L 20190214222750_AddMovie.Designer.cs
  L EfCoreSampleContextModelSnapshot.cs
Models
  L EfCoreSampleContext.cs
  L Movie.cs
Views
  L Movies
    L Create.cshtml
    L Delete.cshtml
    L Details.cshtml
    L Edit.cshtml
    L Index.cshtml
  L Shared
    L _ValidationScriptsPartial.cshtml

View の部分は基本的に Razor で Form を出しているだけなのでスキップすることにして、それ以外の部分を追いかけてみたいと思います。

Movie.cs を見る

C# 側でデータを保持し、マイグレーション時に DB のテーブル生成するのにも使われるクラスです。

Movie.cs

using System;
using System.ComponentModel.DataAnnotations;

namespace EfCoreSample.Models
{
    public class Movie
    {
        public int MovieId { get; set; }
        public string Title { get; set; }

        [DataType(DataType.Date)]
        public DateTime ReleaseDate { get; set; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
    }
}

DataType

ただデータを保持するクラス、ということで、そんなに変わったところも見られません。

唯一気になるのが「[DataType(DataType.Date)]」の属性。

↑などを見ると、どうやら Razor で下記のようなフォームを表示するのに使われているようです。

f:id:mslGt:20190219063847p:plain

試しにコメントアウトしてみると、他と同じようにただ入力エリアが表示されていました。

DB 側に関係があるかはよく分かっていませんが。

EfCoreSampleContext.cs を見る

次は EfCoreSampleContext.cs です。

Entity Framework Core in Action によると DB へのアクセスや設定を担う、ということで、今回の中で一番重要なクラスと言えそうです。

EfCoreSampleContext.cs

using Microsoft.EntityFrameworkCore;

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

        public DbSet< Movie> Movie { get; set; }
    }
}

この中に含まれるキーワードとしては下記の 3 つがあります。

  1. DbContext
  2. DbContextOptions
  3. DbSet

DbContext Class (Microsoft.EntityFrameworkCore) などを見ると、 DB との直接的なやり取りをしているのは DbContext クラスとのこと。

2.の DbContextOptions は、接続する DB の情報などを渡すのに使われるようです。

https://github.com/JonPSmith/EfCoreInAction/blob/Chapter01/MyFirstEfCoreApp/AppDbContext.cs

(関係ないですが、ブランチを本の章を切り替えるのに使うのが新鮮というか、ちょっとびっくりしました)

これをどこから渡しているかというと、 Startup > ConfigureServices() です。

Startup.cs

~省略~
public void ConfigureServices(IServiceCollection services)
{
    services.Configure< CookiePolicyOptions>(options =>
    {
        options.CheckConsentNeeded = context => true;
        options.MinimumSameSitePolicy = SameSiteMode.None;
    });

    services.AddDbContext< EfCoreSampleContext>(options =>
        options.UseSqlite(Configuration.GetConnectionString("MovieContext")));

    services.AddMvc();
}
~省略~

SQLite の使用と Data Source として MvcMovie.db が指定されています。

appsettings.json

~省略~
  "ConnectionStrings": {
    "MovieContext": "Data Source=MvcMovie.db"
  }
}

3.の DbSet は、 DbContext の Set() で Entity クラスとして登録するのに使われるようです。

また、スキャフォールドで生成された MoviesController.cs を見てみると、 DB アクセスの部分は MovieContext(DbContext) 、または DbSet を検索したり、値を取得しているだけのようです。

MoviesController.cs

~省略~
[Route("/Movies/Details/{*id}")]
public async Task< IActionResult> Details(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    var movie = await _context.Movie
        .FirstOrDefaultAsync(m => m.MovieId == id);
    if (movie == null)
    {
        return NotFound();
    }

    return View(movie);
}
~省略~
[HttpPost]
[Route("/Movies/Create")]
[ValidateAntiForgeryToken]
public async Task< IActionResult> Create([Bind("MovieId,Title,ReleaseDate,Genre,Price")] Movie movie)
{
    if (ModelState.IsValid)
    {

        _context.Add(movie);
        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }
    return View(movie);
}
~省略~

ということを考えると、 Entity Framework Core がどんなことをしてくれるのかを知るためには DbContext を中心に調べていくのが良さそうです。

処理を調べるにはドキュメントとコードを見るのが一番、だと思いますが、 Entity Framework Core のコードは下記にあります。

その中の DbContext はこちら。

https://github.com/aspnet/EntityFrameworkCore/blob/master/src/EFCore/DbContext.cs

次回はこれをたどってみることにします。

参照

ASP.NET Core のプロジェクトに EntityFrameworkCore で Model を追加してみた話

はじめに

ようやく ASP.NET Core MVC の Model の話です。

Empty で ASP.NET Core のプロジェクトを作り、 Controller クラスだけを作った状態で、下記チュートリアルを参考に Model を追加してみることにします。

スキャフォールド

今回は ASP.NET Core のプロジェクトを Empty のテンプレートで生成し、そこに追加することにしたいと思います。

チュートリアルにのっとり、 Models > Movie というクラスを作ってみます。

Movies.cs

using System;

namespace WebApplication1.Models {
    public class Movie {
        public int ID { get; set; }
        public string Title { get; set; }
        public DateTime ReleaseDate { get; set; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
    }
}

DbContext クラスを追加します。

DB の設定などを行うためのクラスですが、ここではほぼ空の状態で作成しておきます。

using Microsoft.EntityFrameworkCore;

namespace EfCoreSample.Models
{
    public class EfCoreSampleContext : DbContext
    {
        public EfCoreSampleContext (DbContextOptions options)
            : base(options)
        {
        }
        public DbSet Movie { get; set; }        
    }
}

このチュートリアルでは、既存の DB を使うのではなくコードから生成します。

その名前は appsettings.json で指定しています。

appsettings.json

{
~省略~
  "ConnectionStrings": {
    "MovieContext": "Data Source=MvcMovie.db",
  }
}

必要なパッケージを NuGet でインストールします(どれもバージョンは 2.2.2 にしました)。

Startup クラスで先ほど作成した DBContext を追加します。

Startup.cs

using EfCoreSample.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace EfCoreSample
{
    public class Startup
    {

        public IConfiguration Configuration { get; }
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure< CookiePolicyOptions>(options =>
            {
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });

            services.AddDbContext< EfCoreSampleContext>(options =>
                options.UseSqlite(Configuration.GetConnectionString("MovieContext")));

            services.AddMvc();
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseMvc();
        }
    }
}

そしてスキャフォールド、…といきたいところですが、 Rider では GUI ではスキャフォールドできず、 EntityFramework のコマンドを実行する必要があるようです。

dotnet aspnet-codegenerator controller -name MoviesController -m Movie -dc EfCoreSampleContext --relativeFo
lderPath Controllers --useDefaultLayout --referenceScriptLibraries

エラー

スキャフォールドに成功すると、 View、Controllerにファイルが追加されます。

が、 Views/Movies/Create.cshtml と Views/Movies/Edit.cshtml でエラーが。。。

Create.cshtml

@model EfCoreSample.Models.Movie

@{
    ViewData["Title"] = "Create";
}

< h1>Create< /h1>

< h4>Movie< /h4>
< hr />
< div class="row">
    < div class="col-md-4">
        < form asp-action="Create">
            < div asp-validation-summary="ModelOnly" class="text-danger">< /div>
            < div class="form-group">
                < label asp-for="Title" class="control-label">< /label>
                < input asp-for="Title" class="form-control" />
                < span asp-validation-for="Title" class="text-danger">< /span>
            < /div>
            < div class="form-group">
                < label asp-for="ReleaseDate" class="control-label">< /label>
                < input asp-for="ReleaseDate" class="form-control" />
                < span asp-validation-for="ReleaseDate" class="text-danger">< /span>
            < /div>
            < div class="form-group">
                < label asp-for="Genre" class="control-label">< /label>
                < input asp-for="Genre" class="form-control" />
                < span asp-validation-for="Genre" class="text-danger">< /span>
            < /div>
            < div class="form-group">
                < label asp-for="Price" class="control-label">< /label>
                < input asp-for="Price" class="form-control" />
                < span asp-validation-for="Price" class="text-danger">< /span>
            < /div>
            < div class="form-group">
                < input type="submit" value="Create" class="btn btn-primary" />
            < /div>
        < /form>
    < /div>
< /div>

< div>
    < a asp-action="Index">Back to List
< /div>

@{ // ここでエラー }
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

「Cannot resolve section 'Scripts'」と出ます。

また、エラーの個所をコメントアウトして実行しても、ボタンが動作しません orz

どうしてこうなった/(^o^)\

エラーを何とかする

とりあえずエラーを直します。

やるべきことは、「@section Scripts{}」の削除です。

・・・まじかよ。。。

Create.cshtml

~省略~
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}

これで問題なく動作します。

また、そもそも上記が不要なら削除してしまっても OK です。

ボタンなどが動くようにする

ボタンなどが動作しない原因は、 TagHelper が無いためです。

ということで、先頭に addTagHelper を追加します。

Create.cshtml

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
~省略~

TagHelper については他の画面も同様であるため、同じように追加します。

Visual Studio でスキャフォールドしたときも同じような状態になるのでしょうか。

それともみんな使ってなry

ルーティング

あ、あとここまでの設定だと生成された MovieController のルーティングが効かず、真っ白な画面が表示されてしまうため、この辺で試したように 設定しておきます。

MovieController.cs

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using EfCoreSample.Models;

namespace EfCoreSample.Controllers
{
    public class MoviesController : Controller
    {
        private readonly EfCoreSampleContext _context;

        public MoviesController(EfCoreSampleContext context)
        {
            _context = context;
        }

        [Route("/")]
        [Route("/Movies")]
        // GET: Movies
        public async Task Index()
        {
            return View(await _context.Movie.ToListAsync());
        }
        [Route("/Movies/Details/{*id}")]
        public async Task Details(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var movie = await _context.Movie
                .FirstOrDefaultAsync(m => m.MovieId == id);
            if (movie == null)
            {
                return NotFound();
            }

            return View(movie);
        }
        [Route("/Movies/Create")]
        public IActionResult Create()
        {
            return View("/Views/Movies/Create.cshtml");
        }
        [HttpPost]
        [Route("/Movies/Create")]
        [ValidateAntiForgeryToken]
        public async Task Create([Bind("MovieId,Title,ReleaseDate,Genre,Price")] Movie movie)
        {
            if (ModelState.IsValid)
            {
                _context.Add(movie);
                await _context.SaveChangesAsync();
                return RedirectToAction(nameof(Index));
            }
            return View(movie);
        }
        [Route("/Movies/Edit/{*id}")]
        public async Task Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var movie = await _context.Movie.FindAsync(id);
            if (movie == null)
            {
                return NotFound();
            }
            return View(movie);
        }
        [HttpPost]
        [Route("/Movies/Edit/{*id}")]
        [ValidateAntiForgeryToken]
        public async Task Edit(int id, [Bind("MovieId,Title,ReleaseDate,Genre,Price")] Movie movie)
        {
            if (id != movie.MovieId)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    _context.Update(movie);
                    await _context.SaveChangesAsync();
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!MovieExists(movie.MovieId))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }
                return RedirectToAction(nameof(Index));
            }
            return View(movie);
        }
        [Route("/Movies/Delete/{*id}")]
        public async Task Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var movie = await _context.Movie
                .FirstOrDefaultAsync(m => m.MovieId == id);
            if (movie == null)
            {
                return NotFound();
            }

            return View(movie);
        }
        [HttpPost, ActionName("Delete")]
        [Route("/Movies/Delete/{*id}")]
        [ValidateAntiForgeryToken]
        public async Task DeleteConfirmed(int id)
        {
            var movie = await _context.Movie.FindAsync(id);
            _context.Movie.Remove(movie);
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
        private bool MovieExists(int id)
        {
            return _context.Movie.Any(e => e.MovieId == id);
        }
    }
}

DB のマイグレーション

CSS などが無いため非常に簡素ではあるものの、 CRUD ができそうなページが出来上がりました。

が、そのまま実行すると DB は生成されますが、テーブルが見つからないとエラーになります。

DB にテーブルを追加するには、マイグレーションを実行します。

dotnet ef migrations add AddMovie
dotnet ef database update

CUIGUI より最初とっつきにくい印象があるものの覚えた後は楽なので(個人の感想です)、どちらで慣れるのが良いのかは決めづらいどころですが。

これでとりあえず CRUD ができるようになりました。

次回はもう少し生成されたものの中身を見てみたいと思います。

参照

【C#】Task の話 その 5

はじめに

今回は例外について。

async void と async Task

async/await に関連するアンチパターンの一つに、 「async void を使う」というものがあります。

前回取り上げた await は、戻り値が awaitable (例: TaskAwaiter) である必要があるため、 async void の場合 await できません。

これに起因して、結果がわからないから禁止、という内容となります。

…なんですが、どうしても一番の呼び出し元は async void になる場合があるよねぇ、とも思っていました。

特に Unity の場合、(今だと Job System なるものがあるというので状況が違うかもしれませんが) UI のイベントも同期的なものとして返ってきます。

そこから async/await で処理を実行しようとすれば、 async void を使うことになります。

では、 async void だとどのようにマズいのかを見てみることにします。

※とはいいつつググるといくらでも出てくるんですけど。

Exception がキャッチできない

結果が受け取られないということに類似して、 async void だと Exception がキャッチできない、という問題があります。

Program.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var player = new TaskSamplePlayer();
            
            // 呼び出し先で async/await 使用.
            // 呼び出し元も await 使用.
            try
            {
                await player.PlayAsync();
            }
            catch (Exception e)
            {
                Console.WriteLine("[1] " + e.Message);
            }
            // 呼び出し先では async/await を使用しない.
            // 呼び出し元では await 使用.
            try
            {
                await player.Play();
            }
            catch (Exception e)
            {
                Console.WriteLine("[2] " + e.Message);
            }
            // 呼び出し先では async/await を使用しない.
            // 呼び出し元でも await を使用しない.
            try
            {
                player.Play();
            }
            catch (Exception e)
            {
                Console.WriteLine("[3] " + e.Message);
            }
            // 呼び出し先で async void を使用.
            // void なので呼び出し元では await できない. 
            try
            {
                player.PlayVoidAsync();
            }
            catch (Exception e)
            {
                Console.WriteLine("[4] " + e.Message);
            }   
            Console.WriteLine("end");
        }       
    }
}

TaskSamplePlayer.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    public class TaskSamplePlayer
    {
        public async Task PlayAsync()
        {
            await Task.Run(() =>
            {
                throw new Exception("Error: PlayAsync");
            });
        }
        public Task Play()
        {
            return Task.Run(() => { 
                
                throw new Exception("Error: Play");
            });
        }
        public async void PlayVoidAsync()
        {
            await Task.Run(() =>
            {
                throw new Exception("Error: PlayVoidAsync");
            });
        }
    }
}

結果は下記の通りです。

[1] Error: PlayAsync
[2] Error: Play
end

await してないと try - catch で Exception がキャッチできない、という結果となりました。

理由はいつものごとく?メインスレッドのでの try - catch の処理が先に終わってしまい、後からワーカースレッドで例外が発生する状態になるからですね。

これを考えると、あるメソッドで async void を返す場合、そのメソッドを呼び出すメソッドではなく、自分自身でエラー処理の責務を負う必要があることになります。

複数の Task を実行する

async/await の Exception を調べる中で面白いものを見つけたので。

複数の Task を連続・または同時に実行する場合、その待ち方によって結果が変わる場合があります。

Program.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var player = new TaskSamplePlayer();
            
            Console.WriteLine("1");
            // await
            try
            {
                await player.PlayAsync();
                await player.PlayAsync2();
            }
            catch (Exception e)
            {
                Console.WriteLine("[1] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("2");
            // Wait()
            try
            {
                player.PlayAsync().Wait();
                player.PlayAsync2().Wait();
            }
            catch (Exception e)
            {
                Console.WriteLine("[2] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("3");
            // WaitAll()
            try
            {
                var p1 = player.PlayAsync();
                var p2 = player.PlayAsync2();

                Task.WaitAll(p1, p2);
            }
            catch (Exception e)
            {
                Console.WriteLine("[3] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("4");
            // await WhenAll() 
            try
            {
                var p1 = player.PlayAsync();
                var p2 = player.PlayAsync2();

                await Task.WhenAll(p1, p2);
            }
            catch (Exception e)
            {
                Console.WriteLine("[4] " + e.GetType() + " [Message] " + e.Message);
            }   
            Console.WriteLine("end");
        }       
    }
}

TaskSamplePlayer.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    public class TaskSamplePlayer
    {
        public async Task PlayAsync()
        {
            Console.WriteLine("Start PlayAsync");
            await Task.Run(() =>
            {
                throw new ArgumentException("Error: PlayAsync");
            });
        }
        public async Task PlayAsync2()
        {
            Console.WriteLine("Start PlayAsync2");
            await Task.Run(() => 
            { 
                throw new InvalidOperationException("Error: PlayAsync2");
            });
        }
    }
}

2 つの Task を見分けるため、 Exception の内容を変えたりしていますが、内容は変わらないはずです。

で、結果はこうなります。

1
Start PlayAsync
[1] System.ArgumentException [Message] Error: PlayAsync

2
Start PlayAsync
[2] System.AggregateException [Message] One or more errors occurred. (Error: Pla
yAsync)

3
Start PlayAsync
Start PlayAsync2
[3] System.AggregateException [Message] One or more errors occurred. (Error: Pla
yAsync) (Error: PlayAsync2)

4
Start PlayAsync
Start PlayAsync2
[4] System.ArgumentException [Message] Error: PlayAsync

end

わかりづらいので説明を追加すると

1. await を使って Task をそれぞれ待った場合

2 つの Task の内最初のものだけが実行され、 Task 内で発生した ArgumentException が返っています。

2. Wait() を使って Task をそれぞれ待った場合

2 つの Task の内最初のものだけが実行され、 AggregateException が返っています。

3. WaitAll() を使って Task をまとめて待った場合

両方の Task が実行され、 AggregateException が返っています。

Message には両方の内容が含まれています。

4. await WhenAll() を使って Task をまとめて待った場合

両方の Task が実行され、 Message には両方の内容が含まれますが、 Exception の Type は最初の Task のものだけが含まれています。

Exception の投げ方

今回のサンプルでは 全部 Exception でキャッチする、という雑いやり方をしていたためキャッチできていましたが、ちゃんと Exception の種類ごとに受け取って適切な処理をしようとすると、待ち方にも気を配る必要がありますね。

どこからこの違いは生まれるのでしょうか。

ということでコードを追ってみますよ。

1. await を使って Task をそれぞれ待った場合

await ということで前回登場した TaskAwaiter かな?とも思ったのですが、名前通り Task の完了を待ち受けているだけで、関連しそうなコードは見つかりませんでした。

ということで Task を探ってみます。

なお Task には限らないと思いますが、基本的に Exception は ThrowHelper というクラスが一括で投げているようです。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/ThrowHelper.cs

例えば Task の InternalStartNew ではこのようにしています。

~省略~
if (scheduler == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.scheduler);
~省略~

ThrowHelper の中身を見ると、大量の throw new XXXException と、 ExceptionArgument(enum) などが定義されていました。

ではその 2 も参考にコードを読み進めてみますよ。

ふむふむ。

特に変わったところはないな。。。

。。。

そりゃそうですね。 Task を一つだけ実行して、その中で発生した Exception がそのまま返ってるんだから orz

と、というわけで気を取り直して、 Wait() を見てみますよ。

2. Wait() を使って Task をそれぞれ待った場合

では Wait() ですね。

Wait() もオーバーロードされており、デフォルト値を入れて下記が呼ばれます。

Task.cs

~省略~
public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken)
{
~省略~
    if (IsWaitNotificationEnabledOrNotRanToCompletion) // avoid a few unnecessary volatile reads if we completed successfully
    {
        // Notify the debugger of the wait completion if it's requested such a notification
        NotifyDebuggerOfWaitCompletionIfNecessary();

        // If cancellation was requested and the task was canceled, throw an 
        // OperationCanceledException.  This is prioritized ahead of the ThrowIfExceptional
        // call to bring more determinism to cases where the same token is used to 
        // cancel the Wait and to cancel the Task.  Otherwise, there's a race condition between
        // whether the Wait or the Task observes the cancellation request first,
        // and different exceptions result from the different cases.
        if (IsCanceled) cancellationToken.ThrowIfCancellationRequested();

        // If an exception occurred, or the task was cancelled, throw an exception.
        ThrowIfExceptional(true);
    }

    Debug.Assert((m_stateFlags & TASK_STATE_FAULTED) == 0, "Task.Wait() completing when in Faulted state.");

    return true;
}
~省略~

どうやら関係がありそうなのは ThrowIfExceptional のようです。

Task.cs

~省略~
internal void ThrowIfExceptional(bool includeTaskCanceledExceptions)
{
    Debug.Assert(IsCompleted, "ThrowIfExceptional(): Expected IsCompleted == true");

    Exception exception = GetExceptions(includeTaskCanceledExceptions);
    if (exception != null)
    {
        UpdateExceptionObservedStatus();
        throw exception;
    }
}
~省略~

この GetExceptions で AggregateException が返されています。

あとここでは返ってきた AggregateException をそのまま投げていますね。

3. WaitAll() を使って Task をまとめて待った場合

こちらは WaitAllCore() から AggregateException を投げているようです。

Task.cs

~省略~
private static bool WaitAllCore(Task[] tasks, int millisecondsTimeout, CancellationToken cancellationToken)
{
~省略~
    if (returnValue && (exceptionSeen || cancellationSeen))
    {
        // If the WaitAll was canceled and tasks were canceled but not faulted, 
        // prioritize throwing an OCE for canceling the WaitAll over throwing an 
        // AggregateException for all of the canceled Tasks.  This helps
        // to bring determinism to an otherwise non-determistic case of using
        // the same token to cancel both the WaitAll and the Tasks.
        if (!exceptionSeen) cancellationToken.ThrowIfCancellationRequested();

        // Now gather up and throw all of the exceptions.
        foreach (var task in tasks) AddExceptionsForCompletedTask(ref exceptions, task);
        Debug.Assert(exceptions != null, "Should have seen at least one exception");
        ThrowHelper.ThrowAggregateException(exceptions);
    }

    return returnValue;
}
~省略~

この辺りの扱いの違いは、どこから来ているのでしょうか。

気になるところではありますが、終わりそうにない気がするのでこの辺に留めておきます。

4. await WhenAll() を使って Task をまとめて待った場合

個人的にはこの結果が一番不思議な感じがします。

AggregateException が返るわけではないけど Message はまとめられているという。

Task.cs

~省略~
public static Task WhenAll(params Task[] tasks)
{
 ~省略~
    Task[] tasksCopy = new Task[taskCount];
    for (int i = 0; i < taskCount; i++)
    {
        Task task = tasks[i];
        if (task == null) ThrowHelper.ThrowArgumentException(ExceptionResource.Task_MultiTaskContinuation_NullTask, ExceptionArgument.tasks);
        tasksCopy[i] = task;
    }

    // The rest can be delegated to InternalWhenAll()
    return InternalWhenAll(tasksCopy);
}
~省略~

Task.cs

~省略~
private static Task InternalWhenAll(Task[] tasks)
{
    Debug.Assert(tasks != null, "Expected a non-null tasks array");
    return (tasks.Length == 0) ? // take shortcut if there are no tasks upon which to wait
        Task.CompletedTask :
        new WhenAllPromise(tasks);
}
~省略~

tasks の要素数は 0 ではない( 2 )ため、 WhenAllPromise のインスタンスが返されます。

Task.cs

~省略~
private sealed class WhenAllPromise : Task, ITaskCompletionAction
{
~省略~
    internal WhenAllPromise(Task[] tasks) :
        base()
    {
~省略~
        m_tasks = tasks;
        m_count = tasks.Length;

        foreach (var task in tasks)
        {
            if (task.IsCompleted) this.Invoke(task); // short-circuit the completion action, if possible
            else task.AddCompletionAction(this); // simple completion action
        }
    }

    public void Invoke(Task completedTask)
    {
~省略~
        // Decrement the count, and only continue to complete the promise if we're the last one.
        if (Interlocked.Decrement(ref m_count) == 0)
        {
            // Set up some accounting variables
            List observedExceptions = null;
            Task canceledTask = null;

            // Loop through antecedents:
            //   If any one of them faults, the result will be faulted
            //   If none fault, but at least one is canceled, the result will be canceled
            //   If none fault or are canceled, then result will be RanToCompletion
            for (int i = 0; i < m_tasks.Length; i++)
            {
                var task = m_tasks[i];
                Debug.Assert(task != null, "Constituent task in WhenAll should never be null");

                if (task.IsFaulted)
                {
                    if (observedExceptions == null) observedExceptions = new List();
                    observedExceptions.AddRange(task.GetExceptionDispatchInfos());
                }
                else if (task.IsCanceled)
                {
                    if (canceledTask == null) canceledTask = task; // use the first task that's canceled
                }

                // Regardless of completion state, if the task has its debug bit set, transfer it to the
                // WhenAll task.  We must do this before we complete the task.
                if (task.IsWaitNotificationEnabled) this.SetNotificationForWaitCompletion(enabled: true);
                else m_tasks[i] = null; // avoid holding onto tasks unnecessarily
            }

            if (observedExceptions != null)
            {
                Debug.Assert(observedExceptions.Count > 0, "Expected at least one exception");

                //We don't need to TraceOperationCompleted here because TrySetException will call Finish and we'll log it there

                TrySetException(observedExceptions);
            }
~省略~
        }
        Debug.Assert(m_count >= 0, "Count should never go below 0");
    }
~省略~
}
~省略~

待つ対象となっている Task を一つずつ実行し、失敗したら ExceptionDispatchInfo (発生した Exception の保持やその Exception を投げたりするようです)に追加し、 1 つ以上 Exception があれば TrySetException() に渡しているようです。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Runtime/ExceptionServices/ExceptionDispatchInfo.cs

で、ここから若干ややこしいのですが、この TrySetException() というのは Task< TResult> クラスで定義されているのですが、このクラスは Future.cs にあります。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Threading/Tasks/Future.cs

Future.cs

~省略~
internal bool TrySetException(object exceptionObject)
{
~省略~
    bool returnValue = false;

    // "Reserve" the completion for this task, while making sure that: (1) No prior reservation
    // has been made, (2) The result has not already been set, (3) An exception has not previously 
    // been recorded, and (4) Cancellation has not been requested.
    //
    // If the reservation is successful, then add the exception(s) and finish completion processing.
    //
    // The lazy initialization may not be strictly necessary, but I'd like to keep it here
    // anyway.  Some downstream logic may depend upon an inflated m_contingentProperties.
    EnsureContingentPropertiesInitialized();
    if (AtomicStateUpdate(TASK_STATE_COMPLETION_RESERVED,
        TASK_STATE_COMPLETION_RESERVED | TASK_STATE_RAN_TO_COMPLETION | TASK_STATE_FAULTED | TASK_STATE_CANCELED))
    {
        AddException(exceptionObject); // handles singleton exception or exception collection
        Finish(false);
        returnValue = true;
    }

    return returnValue;
}
~省略~

ここでは AddException() で ExceptionDispatchInfo を追加していますが、このメソッドは Task クラスにあります。

AddException() もオーバーロードがあり、第二引数に false を設定して下記を呼びます。

Task.cs

~省略~
internal void AddException(object exceptionObject, bool representsCancellation)
{
~省略~

    // Lazily initialize the holder, ensuring only one thread wins.
    var props = EnsureContingentPropertiesInitialized();
    if (props.m_exceptionsHolder == null)
    {
        TaskExceptionHolder holder = new TaskExceptionHolder(this);
        if (Interlocked.CompareExchange(ref props.m_exceptionsHolder, holder, null) != null)
        {
            // If someone else already set the value, suppress finalization.
            holder.MarkAsHandled(false);
        }
    }

    lock (props)
    {
        props.m_exceptionsHolder.Add(exceptionObject, representsCancellation);
    }
}
~省略~

ここでは TaskExceptionHolder に ExceptionDispatchInfo を追加しています。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Threading/Tasks/TaskExceptionHolder.cs

Add() では第二引数の値が false のため AddFaultException() を呼んでいます。

TaskExceptionHolder.cs

~省略~
private void AddFaultException(object exceptionObject)
{
    Debug.Assert(exceptionObject != null, "AddFaultException(): Expected a non-null exceptionObject");

    // Initialize the exceptions list if necessary.  The list should be non-null iff it contains exceptions.
    var exceptions = m_faultExceptions;
    if (exceptions == null) m_faultExceptions = exceptions = new List(1);
    else Debug.Assert(exceptions.Count > 0, "Expected existing exceptions list to have > 0 exceptions.");

    // Handle Exception by capturing it into an ExceptionDispatchInfo and storing that
    if (exceptionObject is Exception exception)
    {
        exceptions.Add(ExceptionDispatchInfo.Capture(exception));
    }
    else
    {
        // Handle ExceptionDispatchInfo by storing it into the list
        if (exceptionObject is ExceptionDispatchInfo edi)
        {
            exceptions.Add(edi);
        }
        else
        {
            // Handle enumerables of exceptions by capturing each of the contained exceptions into an EDI and storing it
            if (exceptionObject is IEnumerable exColl)
            {
~省略~
                foreach (var exc in exColl)
                {
~省略~
                    exceptions.Add(ExceptionDispatchInfo.Capture(exc));
                }
~省略~
            }
            else
            {
                // Handle enumerables of EDIs by storing them directly
                if (exceptionObject is IEnumerable ediColl)
                {
                    exceptions.AddRange(ediColl);
~省略~
                }
                // Anything else is a programming error
                else
                {
                    throw new ArgumentException(SR.TaskExceptionHolder_UnknownExceptionType, nameof(exceptionObject));
                }
            }
        }
    }

    if (exceptions.Count > 0)
        MarkAsUnhandled();
}
~省略~

ここでは ExceptionDispatchInfo のリストに値を追加しています。

で、先ほどから出てきている MarkAsHandled() 、 MarkAsUnhandled() というのは GC のファイナライズを実行する・しないを指定しているようです。

ファイナライズで何が実行されるかというと

TaskExceptionHolder.cs

~省略~
~TaskExceptionHolder()
{
    if (m_faultExceptions != null && !m_isHandled)
    {
        // We will only propagate if this is truly unhandled. The reason this could
        // ever occur is somewhat subtle: if a Task's exceptions are observed in some
        // other finalizer, and the Task was finalized before the holder, the holder
        // will have been marked as handled before even getting here.

        // Publish the unobserved exception and allow users to observe it
        AggregateException exceptionToThrow = new AggregateException(
            SR.TaskExceptionHolder_UnhandledException,
            m_faultExceptions);
        UnobservedTaskExceptionEventArgs ueea = new UnobservedTaskExceptionEventArgs(exceptionToThrow);
        TaskScheduler.PublishUnobservedTaskException(m_task, ueea);
    }
}
~省略~

TaskScheduler.PublishUnobservedTaskException() で何をしているかというと、 UnobservedTaskExceptionEventArgs というイベントを実行しています。

ではこの UnobservedTaskException とは何かというと、 Task 内で発生し、処理がされないままの Exception (無視された Exception) をまとめて Exception として投げるためのイベント、ということのようです。

この辺りの Exception の扱いの違いが、結果の違いとなって現れているようですね。

AggregateException とは

Wait() や WaitAll() で使われていた、 AggregateException について少しだけ。

Microsoft Docsによると、アプリケーションの実行中に発生した、 1 つ以上のエラーを表す、とのこと。

なるほどわからん

この辺りの説明を読むと、 複数の Task がそれぞれ非同期で Exception を出しうる場合に、それらを一括で扱えるようにするために AggregateException が使われている、ということのようです。

今回の例では Wait() は単体の Task で使いましたが、親子関係を持たせた場合にこれが必要になるようです。

await では親子関係は持てないの?という疑問はありますが、この辺りは次回以降試してみたいと思います。

なお、 AggregateException が返る場合、 InnerExceptions で個別の Exception を取り出せたり、 AggregateException に親子関係がある場合も Flatten() を使ってまとめて取り出せたりするため便利ですね。

ということで雑に Exception でキャッチせず、ちゃんと AggregateException を指定しましょう(戒め)。

参照

AggregateException

UnobservedTaskException