Entity Framework Core 外部結合

プログラミング
公開 2025年10月7日 最終更新 2025年10月13日

はじめに

Entity Framework Core(EF Core)+ LINQ to Entities では外部結合を表現できます。

  • Orders(注文)テーブル
OrderID CustomerID OrderDate
1 101 2025-10-01
2 101 2025-10-05
3 102 2025-10-03
4 102 2025-10-05

  • Discounts(割引)テーブル
CustomerID StartDate DiscountRate
101 2025-10-01 10
101 2025-10-04 15
102 2025-10-04 5
102 2025-10-07 20

この 2 つのテーブルを例に外部結合を行う方法を見ていきます。

外部結合の基本

LINQ to Entities ではクエリを記述する構文として クエリ構文メソッド構文 の2種類が用意されています。

クエリ構文

外部結合の前に、まず内部結合を見てみます。

// 内部結合
var query = from o in context.Orders
            join d in context.Discounts
            on o.CustomerID equals d.CustomerID
            select new
            {
                o.OrderID,
                o.CustomerID,
                o.OrderDate,
                d.StartDate,
                d.DiscountRate
            };

外部結合は以下のようになります。

// 外部結合
var query = from o in context.Orders
            join d in context.Discounts
            on o.CustomerID equals d.CustomerID into discountGroup
            from d in discountGroup.DefaultIfEmpty()
            select new
            {
                o.OrderID,
                o.CustomerID,
                o.OrderDate,
                d.StartDate,
                d.DiscountRate
            };

外部結合では into discountGroupfrom d in discountGroup.DefaultIfEmpty() を追加しています。

  • into discountGroup

    • Orders の各レコード o に対して、CustomerID が一致する Discounts をグループ化
    • discountGroup は IEnumerable<Discount> 型で、Orders : Discounts = 1 : N
  • from d in discountGroup.DefaultIfEmpty()

    • discountGroup が空でも、DefaultIfEmpty() によって null を代入して d を生成します。これにより Discounts が存在しない Order も結果に含まれます(=外部結合)。
    • DefaultIfEmpty() を使用せず、from d in discountGroup だけを書いた場合は、Discounts が存在しない Orders は結果に含まれないので内部結合になります。

構文についての解説は以上になります。

この query は Entity Framework の各データベースプロバイダによって SQL に変換されてから実行されます。
query.ToQueryString() でどのような SQL に変換されるかを確認することができます。
SQL Server では以下のような SQL が出力されました。

SELECT [o].[OrderID], [o].[CustomerID], [o].[OrderDate], [d].[StartDate], [d].[DiscountRate]
FROM [Orders] AS [o]
LEFT JOIN [Discounts] AS [d] ON [o].[CustomerID] = [d].[CustomerID]

PostgreSQL では以下の SQL になりました。

SELECT o."OrderID", o."CustomerID", o."OrderDate", d."StartDate", d."DiscountRate"
FROM "Orders" AS o
LEFT JOIN "Discounts" AS d ON o."CustomerID" = d."CustomerID"

Oracle です。

SELECT "o"."OrderID", "o"."CustomerID", "o"."OrderDate", "d"."StartDate", "d"."DiscountRate"
FROM "Orders" "o"
LEFT JOIN "Discounts" "d" ON "o"."CustomerID" = "d"."CustomerID"

メソッド構文(GroupJoin + SelectMany)

メソッド構文もまずは内部結合を見てみましょう。

// 内部結合
var query = context.Orders
    .Join(context.Discounts,
        o => o.CustomerID,
        d => d.CustomerID,
        (o, d) => new
        {
            o.OrderID,
            o.CustomerID,
            o.OrderDate,
            d.StartDate,
            d.DiscountRate
        });

外部結合ではこのように変わります。

// 外部結合
var query = context.Orders
    .GroupJoin(context.Discounts,
        o => o.CustomerID,
        d => d.CustomerID,
        (o, discounts) => new { o, discounts  })
    .SelectMany(
        od => od.discounts .DefaultIfEmpty(),
        (od, d) => new
        {
            od.o.OrderID,
            od.o.CustomerID,
            od.o.OrderDate,
            d.StartDate,
            d.DiscountRate
        });
  • GroupJoin

    • Orders の各レコード o に対して、CustomerID が一致する Discounts をグループ化
    • discounts は IEnumerable<Discount> 型で、Orders : Discounts = 1 : N
    • 戻り値は { o, discounts } という匿名型
  • SelectMany

    • SelectMany は フラット化(flatten)の役割
    • DefaultIfEmpty() によって、discounts が空でも null を代入(=外部結合)
    • DefaultIfEmpty() を使用しない場合、内部結合になります
    • 元の Order を含む od とフラット化された discounts の各要素 d を使って新しい匿名型を生成

構文についての解説は以上になります

こちらもどのような SQL になるか確認してみましょう。

-- SQL Server
SELECT [o].[OrderID], [o].[CustomerID], [o].[OrderDate], [d].[StartDate], [d].[DiscountRate]
FROM [Orders] AS [o]
LEFT JOIN [Discounts] AS [d] ON [o].[CustomerID] = [d].[CustomerID]
-- PostgreSQL
SELECT o."OrderID", o."CustomerID", o."OrderDate", d."StartDate", d."DiscountRate"
FROM "Orders" AS o
LEFT JOIN "Discounts" AS d ON o."CustomerID" = d."CustomerID"
-- Oracle
SELECT "o"."OrderID", "o"."CustomerID", "o"."OrderDate", "d"."StartDate", "d"."DiscountRate"
FROM "Orders" "o"
LEFT JOIN "Discounts" "d" ON "o"."CustomerID" = "d"."CustomerID

クエリ構文の場合と全く同じ SQL に変換されています。

クエリの発行と結果の取得

このメモ書きは「外部結合」がテーマなので本題ではありませんが、query を作成した時点ではまだクエリは発行されていません。 クエリの結果が必要になったタイミングでクエリが発行されます。 これは LINQ to Entities の「遅延実行」と呼ばれるものです。

「結果が必要になったタイミング」というのが分かりにくいと思いますが、具体的には、queryforeach で回す、query.ToList()query.FirstOrDefault() を呼び出すなどを行った時になります。

クエリの発行および結果を取得する方法は クエリ構文メソッド構文 どちらでも同じになります。
全件をコンソールに出力する場合は、以下のように書きます。

foreach (var result in query)
{
    Console.WriteLine(result);
}

実行結果

OrderID CustomerID OrderDate StartDate DiscountRate
1 101 2025-10-01 2025-10-01 10
1 101 2025-10-01 2025-10-04 15
2 101 2025-10-05 2025-10-01 10
2 101 2025-10-05 2025-10-04 15
3 102 2025-10-03 2025-10-04 5
3 102 2025-10-03 2025-10-07 20
4 102 2025-10-05 2025-10-04 5
4 102 2025-10-05 2025-10-07 20

大小条件で結合する場合

結合条件に大小関係を含めたい場合があります。
たとえば、OrderDate >= StartDate で「注文日に適用可能な割引」のみを結合したいケースです。

クエリ構文

var query = from o in context.Orders
            join d in context.Discounts
            on o.CustomerID equals d.CustomerID into discountGroup
            from d in discountGroup
                .Where(d => o.OrderDate >= d.StartDate)
                .DefaultIfEmpty()
            select new
            {
                o.OrderID,
                o.CustomerID,
                o.OrderDate,
                d.StartDate,
                d.DiscountRate,
            };

実行してみましょう。

{ OrderID = 1, CustomerID = 101, OrderDate = 2025/10/01 0:00:00, StartDate = 2025/10/01 0:00:00, DiscountRate = 10 }
{ OrderID = 2, CustomerID = 101, OrderDate = 2025/10/05 0:00:00, StartDate = 2025/10/01 0:00:00, DiscountRate = 10 }
{ OrderID = 2, CustomerID = 101, OrderDate = 2025/10/05 0:00:00, StartDate = 2025/10/04 0:00:00, DiscountRate = 15 }
Nullable object must have a value.

4 行目でエラーになってしまいました。 これは 4 行目では Discounts が null になるのですが、結果セットの StartDate の型が DateTime(null 非許容) のためです。

以下のように書き換えてコンパイラに適切な型を教えてあげればよいです。

var query = from o in context.Orders
            join d in context.Discounts
            on o.CustomerID equals d.CustomerID into discountGroup
            from d in discountGroup
                .Where(d => o.OrderDate >= d.StartDate)
                .DefaultIfEmpty()
            select new
            {
                o.OrderID,
                o.CustomerID,
                o.OrderDate,
                StartDate = (DateTime?)d.StartDate,
                DiscountRate = (int?)d.DiscountRate,
            };

以下のように期待する実行結果が得られました。

-- SQL Server
SELECT [o].[OrderID], [o].[CustomerID], [o].[OrderDate], [d].[StartDate], [d].[DiscountRate]
FROM [Orders] AS [o]
LEFT JOIN [Discounts] AS [d] ON [o].[CustomerID] = [d].[CustomerID] AND [o].[OrderDate] >= [d].[StartDate]
-- PostgreSQL
SELECT o."OrderID", o."CustomerID", o."OrderDate", d."StartDate", d."DiscountRate"
FROM "Orders" AS o
LEFT JOIN "Discounts" AS d ON o."CustomerID" = d."CustomerID" AND o."OrderDate" >= d."StartDate"
-- Oracle
SELECT "o"."OrderID", "o"."CustomerID", "o"."OrderDate", "d"."StartDate", "d"."DiscountRate"
FROM "Orders" "o"
LEFT JOIN "Discounts" "d" ON (("o"."CustomerID" = "d"."CustomerID") AND ("o"."OrderDate" >= "d"."StartDate"))
OrderID CustomerID OrderDate StartDate DiscountRate
1 101 2025-10-01 2025-10-01 10
2 101 2025-10-05 2025-10-01 10
2 101 2025-10-05 2025-10-04 15
3 102 2025-10-03
4 102 2025-10-05 2025-10-04 5

期待結果は得られたものの、クエリ構文の中に Where(d => o.OrderDate >= d.StartDate) というメソッド構文の呼び出しが含まれています。このようなクエリ構文とメソッド構文の混在は必ずしも NG とは言えませんが、クエリ構文のみで実現できることの限界を表しています。

メソッド構文(GroupJoin + SelectMany)

var query = context.Orders
    .GroupJoin(context.Discounts,
        o => o.CustomerID,
        d => d.CustomerID,
        (o, d) => new { o, d }
    )
    .SelectMany(x => x.d
        .Where(d => x.o.OrderDate >= d.StartDate)
        .DefaultIfEmpty(),
        (x, d) => new
        {
            x.o.OrderID,
            x.o.CustomerID,
            x.o.OrderDate,
            StartDate = (DateTime?)d.StartDate,
            DiscountRate = (int?)d.DiscountRate,
        }
    );

結果は以下のようになります。

-- SQL Server
SELECT [o].[OrderID], [o].[CustomerID], [o].[OrderDate], [d].[StartDate], [d].[DiscountRate]
FROM [Orders] AS [o]
LEFT JOIN [Discounts] AS [d] ON [o].[CustomerID] = [d].[CustomerID] AND [o].[OrderDate] >= [d].[StartDate]
-- PostgreSQL
SELECT o."OrderID", o."CustomerID", o."OrderDate", d."StartDate", d."DiscountRate"
FROM "Orders" AS o
LEFT JOIN "Discounts" AS d ON o."CustomerID" = d."CustomerID" AND o."OrderDate" >= d."StartDate"
-- Oracle
SELECT "o"."OrderID", "o"."CustomerID", "o"."OrderDate", "d"."StartDate", "d"."DiscountRate"
FROM "Orders" "o"
LEFT JOIN "Discounts" "d" ON (("o"."CustomerID" = "d"."CustomerID") AND ("o"."OrderDate" >= "d"."StartDate"))
OrderID CustomerID OrderDate StartDate DiscountRate
1 101 2025-10-01 2025-10-01 10
2 101 2025-10-05 2025-10-01 10
2 101 2025-10-05 2025-10-04 15
3 102 2025-10-03
4 102 2025-10-05 2025-10-04 5

メソッド構文(SelectMany + Where)

外部結合は GroupJoin を使用せずに以下のように書くこともできます。

var query = context.Orders
    .SelectMany(o => context.Discounts
        .Where(d => o.CustomerID == d.CustomerID && o.OrderDate >= d.StartDate)
        .DefaultIfEmpty(),
        (o, d) => new
        {
            o.OrderID,
            o.CustomerID,
            o.OrderDate,
            StartDate = (DateTime?)d.StartDate,
            DiscountRate = (int?)d.DiscountRate,
        }
    );

SelectMany だけを使用し、結合条件は Where にまとめて記述します。
生成される SQL も GroupJoin を使用したものと全く同じになります。

-- SQL Server
SELECT [o].[OrderID], [o].[CustomerID], [o].[OrderDate], [d].[StartDate], [d].[DiscountRate]
FROM [Orders] AS [o]
LEFT JOIN [Discounts] AS [d] ON [o].[CustomerID] = [d].[CustomerID] AND [o].[OrderDate] >= [d].[StartDate]
-- PostgreSQL
SELECT o."OrderID", o."CustomerID", o."OrderDate", d."StartDate", d."DiscountRate"
FROM "Orders" AS o
LEFT JOIN "Discounts" AS d ON o."CustomerID" = d."CustomerID" AND o."OrderDate" >= d."StartDate"
-- Oracle
SELECT "o"."OrderID", "o"."CustomerID", "o"."OrderDate", "d"."StartDate", "d"."DiscountRate"
FROM "Orders" "o"
LEFT JOIN "Discounts" "d" ON (("o"."CustomerID" = "d"."CustomerID") AND ("o"."OrderDate" >= "d"."StartDate"))
OrderID CustomerID OrderDate StartDate DiscountRate
1 101 2025-10-01 2025-10-01 10
2 101 2025-10-05 2025-10-01 10
2 101 2025-10-05 2025-10-04 15
3 102 2025-10-03
4 102 2025-10-05 2025-10-04 5

GroupJoin よりもコンパクトなコードになっていますので、こちらの形式のほうが良いでしょう。

まとめ

外部結合の基本的な構文と、GroupJoin を使わずに SelectMany だけで外部結合を行う手法をご紹介しました。
公式ドキュメントなどでは GroupJoin + SelectMany で外部結合を行う方法が記載されていますが、SelectMany だけでシンプルに外部結合を記述できます。
ただし、生成・実行される SQL はデータベースプロバイダとそのバージョンによって異なることに注意してください。今回は SQL Server と PostgreSQL、Oracle をとりあげましたが、他のデータベースでは意図しない SQL が生成されたり、実行時に例外が発生するケースもあります。どのような SQL に変換されるかを確認するようにしてください。

使用したデータベースプロバイダのバージョンは以下になります。

  • Microsoft.EntityFrameworkCore.SqlServer 9.0.9
  • Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4
  • Oracle.EntityFrameworkCore 9.23.90