Advent LINQ (16): 表形式と構造化形式

SQL文に変換可能かどうかを、もう少し見てみる。以下のやや複雑なLINQクエリは変換できるだろうか?

using (var context = new AdventureWorksLT2012_DataEntities())
{
    // カスタマーとカスタマー住所を内部結合し、カスタマー住所が"Shipping"のものを抽出
    var customer =
        from customer in context.Customer
        join customerAddress in context.CustomerAddress on
            customer.CustomerID equals customerAddress.CustomerID
        where customerAddress.AddressType == "Shipping"
        select new
        {
            Customer = customer,
            CustomerAddress = customerAddress
        };

    Debug.WriteLine(customer.ToString());
}

このLINQクエリはもちろん成功し、以下のように変換される。

SELECT
    [Extent1].[CustomerID] AS [CustomerID],
    [Extent1].[NameStyle] AS [NameStyle],
    [Extent1].[Title] AS [Title],
    [Extent1].[FirstName] AS [FirstName],
    [Extent1].[MiddleName] AS [MiddleName],
    [Extent1].[LastName] AS [LastName],
    [Extent1].[Suffix] AS [Suffix],
    [Extent1].[CompanyName] AS [CompanyName],
    [Extent1].[SalesPerson] AS [SalesPerson],
    [Extent1].[EmailAddress] AS [EmailAddress],
    [Extent1].[Phone] AS [Phone],
    [Extent1].[PasswordHash] AS [PasswordHash],
    [Extent1].[PasswordSalt] AS [PasswordSalt],
    [Extent1].[rowguid] AS [rowguid],
    [Extent1].[ModifiedDate] AS [ModifiedDate],
    [Extent2].[CustomerID] AS [CustomerID1],
    [Extent2].[AddressID] AS [AddressID],
    [Extent2].[AddressType] AS [AddressType],
    [Extent2].[rowguid] AS [rowguid1],
    [Extent2].[ModifiedDate] AS [ModifiedDate1]
FROM  [SalesLT].[Customer] AS [Extent1]
INNER JOIN [SalesLT].[CustomerAddress] AS [Extent2] ON
    [Extent1].[CustomerID] = [Extent2].[CustomerID]
WHERE N'Shipping' = [Extent2].[AddressType]

完璧に意図通りだ。しかも、匿名クラスへの射影の概念は.NET CLR固有のものだが、SELECT句でカラムに別名を適用して全て吸い上げている。SQL文には表れないが、結果が返された時点で匿名クラスのそれぞれのプロパティに格納されるのだろう。
LINQクエリのjoinは、内部結合に該当する。残念ながら左・右結合、外部結合に対応する予約語は存在しない。これは同時に、LINQ to Objectsでのjoinにも、そのような演算が無いことを意味する。
但し、そもそも左・右結合は、RDBが「表形式」でしか結果を表現できないために存在するものだと分かれば、大した問題ではない。これらの結合は、対応する値が存在しない場合でも、結果表中の該当するカラムに何らかの値を置く必要があり(そうしないと「表」にならない)、仕方がないので「NULL」を返すのだ。
しかし、LINQの世界は表形式だけではなく、(XMLのように)構造化された要素を自在に扱う事が出来る。だから、そこに結果が存在しな場合はNULLで表現しなくても、はじめから存在しない状態を作り出せる。

using (var context = new AdventureWorksLT2012_DataEntities())
{
    // 製品情報をカテゴリー別に集約する
    var productsByProductCategory =
        from product in context.Product
        group product by product.ProductCategoryID;

    Debug.WriteLine(productsByProductCategory.ToString());
}

LINQクエリの「group ~ by」は、名称からSQL分のGROUP BY句を彷彿とさせる。実際、レコード結果をセレクタに従って集約するという点では、GROUP BY句と同じように動作する。
LINQ to Objectsであれば、IGrouping<T, U>インターフェイスの列挙子が返される。このインターフェイスは、IDictionary<T, U>が構造化されたような形をしている。つまり、一つのキー値(T)に対して、n個の要素値(IEnumerable<U>)が格納される。
IDictionaryが「表形式」と考えるなら、IGrouping(IGroupingの列挙子)は「構造化形式」だ。
ただ、得られるSQL文は分かりにくい。

SELECT
    [Project2].[C1] AS [C1],
    [Project2].[ProductCategoryID] AS [ProductCategoryID],
    [Project2].[C2] AS [C2],
    [Project2].[ProductID] AS [ProductID],
    [Project2].[Name] AS [Name],
    [Project2].[ProductNumber] AS [ProductNumber],
    [Project2].[Color] AS [Color],
    [Project2].[StandardCost] AS [StandardCost],
    [Project2].[ListPrice] AS [ListPrice],
    [Project2].[Size] AS [Size],
    [Project2].[Weight] AS [Weight],
    [Project2].[ProductCategoryID1] AS [ProductCategoryID1],
    [Project2].[ProductModelID] AS [ProductModelID],
    [Project2].[SellStartDate] AS [SellStartDate],
    [Project2].[SellEndDate] AS [SellEndDate],
    [Project2].[DiscontinuedDate] AS [DiscontinuedDate],
    [Project2].[ThumbNailPhoto] AS [ThumbNailPhoto],
    [Project2].[ThumbnailPhotoFileName] AS [ThumbnailPhotoFileName],
    [Project2].[rowguid] AS [rowguid],
    [Project2].[ModifiedDate] AS [ModifiedDate]
FROM ( SELECT
    [Distinct1].[ProductCategoryID] AS [ProductCategoryID],
    1 AS [C1],
    [Extent2].[ProductID] AS [ProductID],
    [Extent2].[Name] AS [Name],
    [Extent2].[ProductNumber] AS [ProductNumber],
    [Extent2].[Color] AS [Color],
    [Extent2].[StandardCost] AS [StandardCost],
    [Extent2].[ListPrice] AS [ListPrice],
    [Extent2].[Size] AS [Size],
    [Extent2].[Weight] AS [Weight],
    [Extent2].[ProductCategoryID] AS [ProductCategoryID1],
    [Extent2].[ProductModelID] AS [ProductModelID],
    [Extent2].[SellStartDate] AS [SellStartDate],
    [Extent2].[SellEndDate] AS [SellEndDate],
    [Extent2].[DiscontinuedDate] AS [DiscontinuedDate],
    [Extent2].[ThumbNailPhoto] AS [ThumbNailPhoto],
    [Extent2].[ThumbnailPhotoFileName] AS [ThumbnailPhotoFileName],
    [Extent2].[rowguid] AS [rowguid],
    [Extent2].[ModifiedDate] AS [ModifiedDate],
    CASE WHEN ([Extent2].[ProductID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2]
    FROM   (SELECT DISTINCT
        [Extent1].[ProductCategoryID] AS [ProductCategoryID]
        FROM [SalesLT].[Product] AS [Extent1] ) AS [Distinct1]
    LEFT OUTER JOIN [SalesLT].[Product] AS [Extent2] ON
        ([Distinct1].[ProductCategoryID] = [Extent2].[ProductCategoryID]) OR
        (([Distinct1].[ProductCategoryID] IS NULL) AND ([Extent2].[ProductCategoryID] IS NULL))
    )  AS [Project2]
ORDER BY [Project2].[ProductCategoryID] ASC, [Project2].[C2] ASC

サブクエリバリバリな、読みにくいSQL文が出力された。

  1. ProductCategoryIDをDISTINCTしたリストを作る(一番内側のクエリ)
    つまり、カテゴリーIDの重複のないリストを作る。
  2. 次に、そのリストを使って左結合でCustomerAddressを抽出する(LEFT OUTER JOINのクエリ)
    ここでC1とC2という付加情報を加えている。C1は1固定なので用途が分からないが、C2はカテゴリーに該当するレコードが存在したかどうかを表しているように見える。
  3. 最後に、結合結果をカテゴリーIDとC2でソートする。

このようなSQL文となる理由はあまり思いつかず、はっきりとした理由は不明だ。最終的にカテゴリーID毎に集約しなければならないため、カテゴリーIDでソートするのは理に適っているように思える(逆シリアル化してインスタンスに格納するときに、ソートされていれば順序実行すれば良くなる。件数が莫大かもしれないため、返却後にインメモリでソートしない方が良いだろう)。
何れにしても、このSQL文はLINQ to Entitiesのバージョンが上がる時に変更される可能性があるので深入りしないが、重要なのは、RDBとのやりとりはどうしても「表形式」とするしかないが、結果が戻ってきたら、「構造化形式」に集約出来ると言う事だ。構造化されていれば、結合結果としてカラムチェックに頼らなければならないような、書きにくいコードから解放される。

using (var context = new AdventureWorksLT2012_DataEntities())
{
    // 製品情報をカテゴリー別に集約する
    var productsByProductCategory =
        from product in context.Product
        group product by product.ProductCategoryID;

    // カテゴリー別に製品情報を出力する
    foreach (var categorizedProducts in productsByProductCategory)
    {
        Console.WriteLine("CategoryID: {0}", categorizedProducts.Key);
        foreach (var product in categorizedProducts)
        {
            Console.WriteLine("   Product: {0}, {1}, {2}",
                product.ProductNumber,
                product.Name,
                product.ListPrice);
        }
    }
}

もちろん、LINQクエリなので、パイプライン実行される。SQL Server上ではORDER BYが実行されるので一時リソースを消費するが、.NET側では結果のレコード群が逐次処理される。最終出力はConsoleに出しているだけなので、結果が何千万行あってもメモリは圧迫されない。
今まで苦労してきた開発手法は一体何だったのか、と思わずにはいられないかもしれない。集約の簡便さもさることながら、コードを一瞥しただけで処理内容が把握できるため、保守も容易だ。このコードにはforeach以外に制御構文が存在しない。サイクロマティック複雑度は十分に小さい。それはつまり、テストに掛かるコストも小さいと言う事だ。
最後に、結果をXMLに変換するのに、制御構造が不要である例を示そう。

using (var context = new AdventureWorksLT2012_DataEntities())
{
    // 製品情報をカテゴリー別に集約する
    var productsByProductCategory =
        from product in context.Product
        group product by product.ProductCategoryID;

    // カテゴリー別に製品情報をXMLで出力する
    var categoriesXml = new XElement("Categories",
        from categorizedProducts in productsByProductCategory.AsEnumerable()   // <-- ゲート
        select new XElement(
            "Category",
            new XAttribute("id", categorizedProducts.Key),
            from product in categorizedProducts
            select new XElement("Product",
                new XAttribute("name", product.Name),
                new XAttribute("number", product.ProductNumber),
                new XAttribute("price", product.ListPrice))));

    Debug.WriteLine(categoriesXml);
}

「AsEnumerable」でLINQ to EntitiesとLINQ to XMLの間にゲートを作っていることに注意。これがないと、LINQ to XMLのクエリがLINQ to Entitiesの一部とみなされ、SQL文に変換しようとして失敗する。
このコードのユニットテストの想像が付くだろうか? 従来の制御構文によるロジックバリバリのコードのユニットテストと異なり、このコードのテストは純粋なインターフェイス境界のテストとして非常に設計しやすいはずだ。この事を知っていれば、機能設計の段階から設計方針が異なってくる。
(但し、ユニットテストとして成立させるためには、DataContextを分離する必要がある事に注意。このままではデータベースが存在しないとテスト出来ないので良くない)。

投稿者:

kekyo

A strawberry red slime mold. Likes metaprogramming. MA. Bicycle rider. http://amzn.to/1SeuUwD