WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Linq の GroupBy

by WebSurfer 2020年12月4日 12:33

Linq to Objects で GroupBy メソッドを使って IEnumerable<IGrouping<TKey, TElement>> オブジェクトを取得できるということを備忘録として書いておきます。

SELECT クエリ

SQL 文の GROUP BY 句はグループごとの集計を取るために使われるものと理解しています。例えば上の画像にあるように、Products テーブルから SupplierID 別に商品単価の最高と最低の値を取得するというように。(DB は Microsoft が提供するサンプルデータベース Northwind です)

なので、GROUP BY 句で指定したフィールド(上の例では SupplierID)以外や、最大値と最小値を返す MAX(), MIN()、合計を返す SUM() や平均を返す AVG() など以外を SELECT することはできません。

例えば、上の画像の SELECT クエリで ProductName を SELECT しようとすると "列 'NORTHWIND.dbo.Products.ProductName' は選択リスト内では無効です。この列は集計関数または GROUP BY 句に含まれていません" というエラーになります。

Linq でも同様で、下のような Select メソッドなしで使うものではないと思っていました。

var result = await _context.Products.GroupBy(p => p.SupplierId).
                   Select(g => new 
                   { 
                       Id = g.Key, 
                       Max = g.Max(g => g.UnitPrice), 
                       Min = g.Min(g => g.UnitPrice) 
                   }).
                   ToListAsync();

Select メソッドなしで使うと、例えば以下のようにすると "Client side GroupBy is not supported" というエラーになります。(注: これは Core 3.0 以降の話で、Core 2.x 以前はサポートされていたそうです。.NET Framework の EF でもサポートされています。詳しくは Breaking changes included in EF Core 3.x の最初のセクション LINQ queries are no longer evaluated on the client を見てください)

var result1 = await _context.Products.GroupBy(p => p.SupplierId).
                    ToListAsync();

// または

var result2 = await (from p in _context.Products
                     group p by p.SupplierId into g
                     select g).ToListAsync();

そのエラーの原因は Linq to Entities で上のような GroupBy を使った Linq 式は SQL 文に変換できないということのようです。(ただし、Core 2.x 以前と .NET Framework では EF 側でオブジェクトの生成をサポートしていて、上記のコードでも OK です)

でも、Linq to Objects の場合は話が違ってきます。以下のコードは ASP.NET Core MVC のアクションメソッドですが、IEnumerable<IGrouping<int?, Products>> オブジェクトを model に取得して View に渡すことができています。

public async Task<IActionResult> ListBySupplier2()
{
    // Linq to Objects で GroupBy を使うのは問題ない
    List<Products> productList = await _context.Products.ToListAsync();
    var model = productList.GroupBy(p => p.SupplierId);

    return View(model);
}

Visual Studio 2019 のデバッガで上のコードの model を展開すると以下の画像のようになっています。

デバッガで見た model

これが全くの予想外で驚いたので備忘録として残しておくことにした次第です。(自分が無知なだけということかもしれませんけど)

これが何の役に立つのかと言われると答えに窮しますが、例えば以下の View を使って SupplierID 別にテーブルを作ると言ったことができます。

@model IEnumerable<IGrouping<int?, Products>>

@{
    ViewData["Title"] = "ListBySupplier2";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>ListBySupplier2</h1>

@foreach (var item in Model)
{
    <table class="table">
        <thead>
            <tr>
                <th>
                    @Html.DisplayNameFor(m => item.First().ProductId)
                </th>
                <th>
                    @Html.DisplayNameFor(m => item.First().ProductName)
                </th>
                <th>
                    @Html.DisplayNameFor(m => item.First().SupplierId)
                </th>
                <th>
                    @Html.DisplayNameFor(m => item.First().UnitPrice)
                </th>
            </tr>
        </thead>
        <tbody>
            @foreach (var product in item)
            {
                <tr>
                    <td>
                        @Html.DisplayFor(m => product.ProductId)
                    </td>
                    <td>
                        @Html.DisplayFor(m => product.ProductName)
                    </td>
                    <td>
                        @Html.DisplayFor(m => product.SupplierId)
                    </td>
                    <td>
                        @Html.DisplayFor(m => product.UnitPrice)
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

上のコードで foreach (var item in model) の item は IGrouping<int?, Products> に、foreach (var product in item) の product は Products になります。

結果、ブラウザには以下のように SupplierID 別のテーブルが表示されます。

結果

ORDER BY [SupplierID] で並べて一つのテーブルに表示した方がスマートかつ見やすいと思いますが、それはとりあえず置いときます。(笑)

Tags: , ,

ADO.NET

Linq to Entities / Objects

by WebSurfer 2019年2月13日 15:17

Linq to Entities は、Linq to Objects とは違って、そのクエリ式が SQL Server などの DB で使われる SQL に変換できる必要があるという話を書きます。CodeZine の記事「LINQにも色々 ~SQLに変換されるモノと変換されないモノ」を見てください。図だけ借用して以下にも貼っておきます。

Linq to Objects / Enitities

その図を見れば一目瞭然だと思いますし、詳しいことは CodeZine の記事を読めば分かるのですが、それで終わってしまってはブログの記事としては面白くないので、どういう事例があったか(要するに失敗談)を書いておきます。(笑)

まず以下の例。これを実行すると ToList() のところで "System.NotSupportedException: LINQ to Entities does not recognize the method 'System.DateTime Parse(System.String)' method, and this method cannot be translated into a store expression." というエラーが出ます。

public class Filter
{
    public int Id { get; set; }
    public DateTime? Date { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // ADO.NET Entity Data Model
        TestDatabaseEntities ctx = new TestDatabaseEntities();
            
        var list = (from c in ctx.TestTable
                    select new Filter
                    {
                        Id = c.ID,
                        Date = DateTime.Parse(c.Date)
                    }).ToList();
    }
}

上の図の右側に「SQL に変換してデータベース上で実行」とありますが、DateTime.Parse メソッドが SQL に変換できないということでエラーになったということです。

上���コードで Filter クラスを初期化して Id, Date に代入するところからは C# に戻ってきて行うのかと期待してましたが、そうではなくて ToList の前まで全部 SQL に変換して SQL Server で実行しようとするようです。

ちなみに、Filter クラスの Date プロパティを string 型に変更して、DateTime.Parse なしで直接 c.Data を代入するようにすれば SQL に変換出来るようで、エラーなく期待した結果が得られます。

他の解決策としては、LINQ to Entities クエリで使用する SQL Server 関数を公開する SqlFunctions クラスのメソッドに該当するものがあれば、それの利用を検討するのがよさそうです。具体例は、Microsoft のドキュメント「方法: データベース関数を呼び出す」のサンプルコードを見てください。

上のコードの DateTime.Parse は該当する SqlFunctions クラスのメソッドは見つかりませんでしたが、「方法: カスタム データベース関数を呼び出す」にある方法も考えれば対応可能かもしれません。(未検証・未確認です)

なお、自分の環境 (Windows 10, VS2015, .NET 4.6.1, EF 6.2) では、上記の記事のリンク先の名前空間: System.Data.Objects.SqlClient の SqlFunctions クラスのメソッドでは NotSupportedException となり、名前空間: System.Data.Entity.SqlServer の SqlFunctions Class のメソッドを使う必要がありました。他の環境でどうかは分かりませんが注意してください。

もう一つは以下の例。これはコードのコメントにも書いてある通り delivery も test も Linq to Entities のクエリ式で、両方を合体して SQL に変換でき、foreach で DB に SQL を投げることができるので問題なく期待した結果が得られます。(Northwind の Order Details, Products テーブルから ADO.NET Entity Data Model を作成して使っています)

// これは Linq to Entities
var delivery = 
    from d in context.Order_Details
    group d by d.ProductID into g
    orderby g.Key
    select new
    {
       ItemCode = g.Key,
        Count = g.Sum(x => x.Quantity),
        SumAmount = g.Sum(x => x.UnitPrice * x.Quantity)
    };

// これも Linq to Entities
var test = from p in context.Products
           join d in delivery
           on p.ProductID equals d.ItemCode into dGroup
           from item in dGroup.DefaultIfEmpty()
           select new
           {
               ItemCode = p.ProductID,
               Name = p.ProductName,
               Count = item.Count,
               SumAmount = item.SumAmount
           };

// delivery を含めた test のコード全体を Linq to Entities と
// して SQL に変換することができ、foreach で DB に SQL を投げ
// ることができるので問題ない。
foreach (var x in test)
{
    Console.WriteLine(
      $"Name: {x.Name}, Count: {x.Count}, Sum: {x.SumAmount}");
}

上のような複雑なクエリ式が SQL に変換できるというのが驚きですが、それはちょっと置いといて、失敗事例はどういうことだったのかを書きます。

それは、DataTable から Linq to Objects のクエリ式を使って匿名型のオブジェクトのコレクションを取得し、それを Linq to Entities のクエリ式に組み合わせたことです。

具体的には、上のコードの delivery を以下のように DataTable(コードの table がそれ)から取得するようにしました。

// これは Linq to Object
var delivery = 
    from d in table.AsEnumerable()
    group d by d.Field<int>("ProductID") into g
    orderby g.Key
    select new
    {
        ItemCode = g.Key,
        Count = g.Sum(x => x.Field<Int16>("Quantity")),
        SumAmount = g.Sum(x => x.Field<decimal>("UnitPrice") * 
                               x.Field<Int16>("Quantity"))
    };

そうすると foreach のところで " System.NotSupportedException: Unable to create a constant value of type 'Anonymous type'. Only primitive types or enumeration types are supported in this context." というエラーになります。

日本語では "System.NotSupportedException: 型 '匿名型' の定数値を作成できません。このコンテキストでサポートされるのはプリミティブ型または列挙型だけです" となります。

エラーメッセージが前者の例とは違っていてため、最初、原因が分からなかったのですが、匿名型のオブジェクトのコレクションを Linq to Entities のクエリに組み込むと SQL に変換できないということが問題のようです。

なお、匿名型でなく、別にクラスとプロパティを定義し、それを初期化して各プロパティに代入するようにしても、上のエラーメッセージの 'Anonymous type' が定義したクラス名に変わるだけで同じエラーになります。

解決策は test のクエリ式も Linq to Objects にすることで、具体的には context.Products を context.Products.ToList() にすれば期待した結果が得られます。

Tags:

ADO.NET

Linq to Entities で型の初期化

by WebSurfer 2018年4月30日 16:31

以下のコードを実行すると foreach (var item in list2) のところで System.NotSupportedException がスローされ、

1 つの LINQ to Entities クエリに含まれる構造的に互換性のない 2 つの初期化に、型 'ConsoleAppJoinByLinq2.JoinedList' が指定されています。1 つの型を同じクエリ内の 2 つの場所で初期化することはできますが、両方の場所で同じプロパティが同じ順序で設定されている必要があります。

・・・というエラーメッセージが表示されます。その理由と解決策を書きます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleAppJoinByLinq2
{
  public class JoinedList
  {
    public int ProductID { set; get; }
    public string ProductName { get; set; }
    public int CategoryID { get; set; }
    public string CategoryName { set; get; }
  }

  class Program
  {
    static void Main(string[] args)
    {
      NORTHWINDEntities db = new NORTHWINDEntities();

      var list1 = from c in db.Categories
                  where c.CategoryID == 1
                  select new JoinedList
                  {
                    CategoryID = c.CategoryID,
                    CategoryName = c.CategoryName
                  };

      var list2 = from c in list1
                  join p in db.Products
                  on c.CategoryID equals p.CategoryID
                  select new JoinedList
                  {
                    ProductID = p.ProductID,
                    ProductName = p.ProductName,
                    CategoryName = c.CategoryName
                  };

      foreach (var item in list2)
      {
        Console.WriteLine("{0}, {1}, {2}",
          item.ProductID, item.ProductName, item.CategoryName);
      }
    }
  }
}

上のコードでは、SQL Server のサンプルデータベース Northwind をベースに Visual Studio Communirt 2015 のウィザードを使って作った Entity Data Model (EDM) を使っています。以下の画像を見てください。

Entity Data Model

NORTHWINDEntities は EDM を作ると一緒に自動生成される DbContext クラスを継承したコンテキストクラスです。

Categories と Products は NORTHWINDEntities コンテキストクラスのプロパティで、データベースの当該テーブルを表すエンティティのコレクションを取得・設定するものです。

そして、肝心の話の何故エラーになるかの理由ですが、エラーメッセージの「両方の場所で同じプロパティが同じ順序で設定」という条件が満たされてない、即ち、list1 と list2 のクエリで JoinedList を初期化する際のプロパティの設定が異なるからです。

その前のエラーメッセージの条件「1 つの型を同じクエリ内の 2 つの場所で初期化」には該当しないように見えますが、list1 と list2 のクエリは両方 foreach のところで遅延評価されて、結局「同じクエリ内」ということになるようです。

解決策は、

  1. list1 のクエリに ToList() を適用する(遅延評価されないように)、または、
  2. JoinedList を初期化する際のプロパティの設定を、並び順序を含めて list1 / list2 のクエリで同じになるようにする

・・・です。そうすれば、以下の通り期待した結果が得られます。

実行結果

Tags:

ADO.NET

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar