0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[VB.NET]LINQのクエリ式の複数のFrom句、及び左外部結合はどのように処理されるのか?

Last updated at Posted at 2021-06-17

LINQのクエリ式に複数のfrom句がある場合、どのように処理されるのかを考えてみました。

LINQのクエリ式に複数のfrom句がある場合

Dim alphabets = {"a", "b"}
Dim numbers = {1, 2, 3}

Dim query = 
	from alp in alphabets
	from num in numbers
	select alp, num

For Each item in query
	Console.WriteLine( item.alp & ", " & item.num )
Next

こんなふうに、2つのリスト(実際はテーブルのレコードになると思いますが)があった時に、それらをfrom句でそのまんま繋ぐと、以下のようにalphabetsとnumbersの総当たり結果が出力されます。

a, 1
a, 2
a, 3
b, 1
b, 2
b, 3

これはSQLで次のように書いた時と同じです。

SELECT * FROM alphabets, numbers

内部的には次のようにFor Eachループの入れ子処理がされている、という説明を読んだことがあります。

Dim alphabets = {"a", "b"}
Dim numbers = {1, 2, 3}

Dim query =
	From alp In alphabets
	From num In numbers
	Select alp, num

For Each item In query
    Console.WriteLine(item.alp & ", " & item.num)
Next

Dim list = query.ToList()
list.Clear()
For Each alp In alphabets
    For Each num In numbers
        list.Add(New With {Key .alp = alp, Key .num = num})
    Next
Next

For Each item In query
    Console.WriteLine(item.alp & ", " & item.num)
Next

つまり、alphabetsとnumbersが入れ子のループになっている、という事のようです。
確かに結果は同じく、以下のようになります。

a, 1
a, 2
a, 3
b, 1
b, 2
b, 3

親子構造を持つデータで複数のfrom句

それでは、次のような、親子構造を持つデータを扱う場合には、どのように考えればよいのでしょうか。
Parentクラスは、Childrenという文字列のリストを持っています。

Class Parent
	Public Name As String
	Public Children As New List(Of String)
End Class

このデータについて、parentとchildの二つのfrom句を指定してみます。

Dim parents as New List(Of Parent) From {
	New Parent With {.Name = "Parent1", .Children = New List(Of String) From {"Child1", "Child2"} },
	New Parent With {.Name = "Parent2", .Children = New List(Of String) From {"Child3", "Child4"} } 
}

Dim query = 
	From parent In parents
	From child In parent.Children
	Select parent.Name, child

For Each item In query
	Console.WriteLine(item.Name & ", " & item.Child)
Next

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

Parent1, Child1
Parent1, Child2
Parent2, Child3
Parent2, Child4

なんとなく、こうなったりしないのかなと思ったのが、ならないようです。

Parent1, Child1
Parent1, Child2
Parent1, Child3
Parent1, Child4
Parent2, Child1
Parent2, Child2
Parent2, Child3
Parent2, Child4

しかしこれも、For Eachループの入れ子と考えれば、当然の結果です。

For Each parent In parents
    For Each child In parent.Children
        list.Add(New With {Key .Name = parent.Name, Key .child = child})
    Next
Next

複数のFrom句はSelectManyとしても捉えられる

ただ、どうも、「For Eachループの入れ子」という考え方が、Linqをメソッドチェーンとして捉えようとした時に相性が悪く、「結局今、リストの状態はどうなっているのか?」というのがいまいちピンと来ません。

色々と探っていると、複数のfrom句は、SelectManyとして実装されているという話を見つけました(本当かどうか知りませんが)。
SelectManyとは、基本的には、リストの各要素1つから複数のリスト構造が出力されるような場合に、各要素からのリストを1つのリストにまとめて出力するというものです。

いくつかの使い方がありますが、そのうちの一つは、階層構造を持つデータに対して、「親から子のリストを取得するラムダ式」と、「その結果得られる子のリストのそれぞれの各要素と、その親を両方受け取って何らかのデータを返すラムダ式」を与えて、後者のラムダ式の結果のリストの合成結果を生成するメソッドになります。

具体的には次のように使います。

Dim parents as New List(Of Parent) From {
	New Parent With {.Name = "Parent1", .Children = New List(Of String) From {"Child1", "Child2"} },
	New Parent With {.Name = "Parent2", .Children = New List(Of String) From {"Child3", "Child4"} } 
}

Dim query = parents.SelectMany(
	Function(parent) parent.Children, 
	Function(parent, child) New With {Key .Name = parent.Name, Key .child = child}
)

For Each item In query
	Console.WriteLine(item.Name & ", " & item.Child)
Next

結果は次のようになります。クエリ式と同じ結果ですね。

Parent1, Child1
Parent1, Child2
Parent2, Child3
Parent2, Child4

親子関係があるわけではありませんが、最初の「alphabetsとnumbers」の例も、SelectManyに置き換えるとこうなります。
最初のラムダ式で、alpを受け取って(alpとは無関係の)numbersを返す事で、結果的にループの入れ子のように処理されます。

Dim alphabets = {"a", "b"}
Dim numbers = {1, 2, 3}

Dim query = alphabets.SelectMany(
	Function(alp) numbers, 
	Function(alp, num) New With {Key .alp = alp, Key .num = num}
)

For Each item in query
	Console.WriteLine( item.alp & ", " & item.num )
Next
結果
a, 1
a, 2
a, 3
b, 1
b, 2
b, 3

クエリ式でテーブル結合を行う

このあたりを理解していくと、クエリ式でテーブル結合を行うケースについても理解できてきます。

以下のように、社員Empと部署Deptがあり、EmpとDeptがdeptnoでつながっているとします。
社員がどの部署に所属しているかを、deptnoで表しています。

Class Emp
	Public empno As Integer
	Public deptno As Integer
	Public empname As String
End Class

Class Dept
	Public deptno As Integer
	Public deptname As String
End Class

Dim employees = New List(Of Emp) From {
	New Emp With { .empno = "1", .deptno = "1", .empname = "社員1" },
	New Emp With { .empno = "2", .deptno = "1", .empname = "社員2" },
	New Emp With { .empno = "3", .deptno = "2", .empname = "社員3" },
	New Emp With { .empno = "4", .deptno = "2", .empname = "社員4" },
	New Emp With { .empno = "5", .deptno = "3", .empname = "社員5" }
}

Dim departments = New List(Of Dept) From {
	New Dept With { .deptno = "1", .deptname = "部署1" },
	New Dept With { .deptno = "2", .deptname = "部署2" }
}

この2つのデータを左結合して、「社員一覧に、その所属部署名を表示する」ということをします。

ダメな例
Dim query = 
	From emp In employees
	From dept In departments
	Select emp.empname, dept.deptname

For Each item In query
	Console.WriteLine( item.empname & ", " & item.deptname )
Next

この例は動作はしますが、結果は「総当たり」となってしまいます。deptnoで紐づいてくれません。

社員1, 部署1
社員1, 部署2
社員2, 部署1
社員2, 部署2
社員3, 部署1
社員3, 部署2
社員4, 部署1
社員4, 部署2
社員5, 部署1
社員5, 部署2

クエリ式で内部結合(Where句版)

そこで、結合条件を追加します。

joinを使わない内部結合(遅い)
Dim query = 
	From emp In employees
	From dept In departments 
	Where dept.deptno = emp.deptno
	Select emp.empname, dept.deptname

For Each item In query
	Console.WriteLine( item.empname & ", " & item.deptname )
Next

この例は正しく結合した結果を返しますが、実際にどのように処理されているのかを考えると、結局「総当たり処理」をしてから、deptnoが一致する結果だけを絞り込んでいるだけということが、今回の記事の結果からみて取れます。この程度の分量のデータなら問題はありませんが、何万件ものデータを処理するようなパターンだと大きな問題になります。

社員1, 部署1
社員2, 部署1
社員3, 部署2
社員4, 部署2

クエリ式で内部結合(Join句版)

この問題を解決するのがJoin句です。
左辺(この場合はemp)に対して右辺(この場合はdept)が1件以上存在する場合、自動的に絞り込みつつ対応付けてくれます。

Join句を使う内部結合(速い)
Dim employees = GetEmpList()
Dim departments = GetDeptList()

Dim query = 
	From emp In employees
	Join dept In departments 
	On dept.deptno Equals emp.deptno
	Select emp.empname, dept.deptname

For Each item In query
	Console.WriteLine( item.empname & ", " & item.deptname )
Next
社員1, 部署1
社員2, 部署1
社員3, 部署2
社員4, 部署2

Group Join句による左外部結合

Join句はとても便利ですが、問題は、互いに一致するレコードがない場合、そのレコードは存在しないことになってしまう、つまりSQLでいうところの内部結合になってしまうことです。

今回の例でいうと、社員5のdeptno=3はdepartmensに存在しない為、そもそもリストアップされていません。

今回は左外部結合(SQLのLEFT OUTER JOIN)として、deptに対応するdeptnoがなかった場合には、deptnameは空文字で表示したいと思います。

左外部結合をクエリ式で実現する場合には、Group Join句を用います。Group Join句は左結合専用というわけではありませんが、左辺(ここではemp)1つに対して右辺(ここではdept)が「0件以上」紐づくケースで使用します。Join句は、1行1行を対応付けていくので、0件のものは存在しないことになりますが、Group Joinだと、結合されるものを集合として扱うので、0件なら0件として処理ができるのです。

そして、この特徴を利用して、左外部結合に利用することができます。

(C#ではjoin into句を使いますが、VB.NETのクエリ式ではGroup Join句になります。この辺最初、混乱したのでご注意ください。どうしてVBとC#でクエリ式の文法を変えてしまったんでしょうかね?)。

Group-Joinで左外部結合
Dim query = 
	From emp In employees
    Group Join dept In departments
        On dept.deptno Equals emp.deptno
        Into tmp = Group
    From tmp2 In tmp.DefaultIfEmpty(New Dept)
    Select emp.empname, tmp2.deptname

念のため、C#版ではこのようになります。

var query =
	from emp in employees
	join dept in departments
		on dept.deptno equals emp.deptno 
		into tmp
	from tmp2 in tmp.DefaultIfEmpty(new Dept())
	select new { emp.empname, tmp2.deptname }

Group Join句を使う場合、結合条件には(Where句と違い)Equals句を用います。
残念ながら、Equals以外の結合条件は使えません。複数ある場合はAndで結合します。
もし、結合条件に「あるフィールドが100以上」等の、Equals以外の条件がある場合、先にdepartmentsをWhere句で絞り込んでおくと良いでしょう。

次に、Into句です。
Group Joinの結合条件で絞り込まれたdepartmentsが、Into句で指定した変数に入ります。

Into tmp = Group

この「Group」はキーワードで、Enumerable(Of T)型です。ここではEnumerable(Of Dept)になります。
例えば1件目のemp.deptno=1 に対するdepartmentsの絞り込み結果2件がGroupに入っています。
それをtmpという変数に格納しています。

Into句には、Group、つまりEnumerable(Of Dept)型の値がGroupというキーワード変数で入力されてきます。
ですので、次のようには書けません。

これは書けない
Into deptname = dept.deptname

deptに直接アクセスすることはできません(もっというと、empにもアクセスできません)。
Groupそのものか、Group(つまりdeptのリスト)に対する集合関数だけが、ここでは使用できます。

ですので、次のように書くことはできます。

これは書ける(意味はないが)
Into maxdeptno = Max(dept.deptno)

これもOKです。

これは書ける(意味はないが)
Into maxdeptno = Max(emp.deptno)

SQLでよく書く、Group By指定していない項目を引っ張ってくるやり方みたいな感じですね。

ちなみに、残念ながら規定では、文字列型変数に対してMax集合関数を適用することはできません(そのような型を受け入れるMaxは定義されていない、というエラーになります)。

エラーになる
Into deptname = Max(dept.deptname)

ですが、もしそういうことがしたければ、Enumerable(Of Dept)の拡張メソッドとして、文字列を受け取って、最も評価値が大きい文字列を返すようなMax関数を作れば、上記もエラーにならなくなるでしょう(試していないのでわかりませんが、たぶん大丈夫です)。

ですので、通常は次のように、Groupそのものを一旦別の変数に格納し、そのGroupをFrom句を使ってまた個々の要素に分解します。

Into tmp = Group
From tmp2 In tmp.DefaultIfEmpty(New Dept)

分かりますでしょうか?
Group Joinの結果がtmp(型はEnumerable(Of Dept))に入り、それをまたFromでtmp2(型はDept)に分解して処理しています。

ここで、tmpを直接Inに入れるのではなく、tmp.DefaultIfEmptyメソッドを介しているのが、今回の本題になります。

もしここが、次のようになっていたらどうでしょうか。

Into tmp = Group
From tmp2 In tmp

tmpが空のリストだった場合(つまり、emp.deptno=5の時のケースです)、tmp2は生成されず、結局それに対する最後のSelect句も実行されないことになります。

それではわざわざGroup Join句を使った意味がありません。そこで、「空だったら既定のEmpty値を返す」ようにして、処理自体は実行するのです。
それがDefaultIfEmptyです。

DefaultIfEmpty(New Dept)

引数に入っている New Deptは、「空だった場合に、Empty値として使用したい値」です。これを指定しないと、そもそも要素自体がNothingで渡されてしまいます。

そして、最後のSelect句で、値を取り出します。

再掲-Group-Join句で左外部結合
Dim query = 
	From emp In employees
    Group Join dept In departments
        On dept.deptno Equals emp.deptno
        Into tmp = Group
    From tmp2 In tmp.DefaultIfEmpty(New Dept)
    Select emp.empname, tmp2.deptname

DefaultIfEmptyにNew Deptを渡さないと、ここのtmp2がNothingなので、tmp2.deptnameがNullReferenceExceptionで失敗します。
逆に言うと、次のようにかけば問題ありません。

Dim query = 
	From emp In employees
	Group Join dept In departments
		On dept.deptno Equals emp.deptno
		Into tmp = Group
	From tmp2 In tmp.DefaultIfEmpty()
	Select emp.empname, deptname = If(tmp2 IsNot Nothing, tmp2.deptname, String.Empty)

尚、C#だとNull許容型演算子「??」が使えるので、このあたりはもっとシンプルに書けます。

var query =
	from emp in employees
	join dept in departments
		on dept.deptno equals emp.deptno 
		into tmp
	from tmp2 in tmp.DefaultIfEmpty()
	select new { emp.empname, deptname = tmp2?.deptname ?? String.Empty }

VB.NETは進化が止まってしまったので、残念ですね…。

EntityFrameworkで左外部結合をする際の注意

尚、Group Join句のDefaultIfEmptyには、EntityFrameworkで使う際に1点、注意があります。

今回、employeesとdepartmentsはソースコード上で生成したメモリ上のオブジェクトですが、もしここでemployeesとdepartmentsがEntityFrameworkで定義しているエンティティオブジェクトだった場合、つまり、実際のテーブルと紐づいているものだった場合、クエリ式は内部でSQLを生成してDBに問い合わせを行おうとします。

すると、以下のクエリ式は、それを実際に評価しようとした時(ToListやToArray、又はCountの取得等)に、例外を発生させます。

再掲-Group-Join句で左外部結合
Dim query = 
	From emp In employees
    Group Join dept In departments
        On dept.deptno Equals emp.deptno
        Into tmp = Group
    From tmp2 In tmp.DefaultIfEmpty(New Dept)
    Select emp.empname, tmp2.deptname
System.NotSupportedException: 'Unable to create a constant value of type 'Dept'. Only primitive types or enumeration types are supported in this context.'
「Dept型の定数値を生成することはできません。このコンテキストではプリミティブ型か列挙型のみがサポートされています。」

これは、クエリ式をSQLに変換しようとして、「SQL上で空のDeptを1行生成するってどういうこと?」となるからでしょう(多分)。
EmpやDeptがエンティティオブジェクトだった場合には、次のように、引数無しのDefaultIfEmptyが、「空の行」を自動的に生成してくれます。

Dim query = 
	From emp In employees
	Group Join dept In departments
		On dept.deptno Equals emp.deptno
		Into tmp = Group
	From tmp2 In tmp.DefaultIfEmpty()
	Select emp.empname, deptname = If(Not String.IsNullOrEmpty(tmp2.deptname), tmp2.deptname, String.Empty)

ここで、tmp2はNothingではなく、インスタンスを持っています。しかしそのプロパティは全て、規定値となっています。
文字列型であればNothing(Null)が規定値ですので、tmp2.deptnameはNothing(Null)になって渡されます。

ですので、対応するDeptがない場合にdeptnameを空文字にしたい場合は、ちゃんと変換しなければなりません。

ちなみに、次のように、tmpからdeptnameだけを取り出した結果にDefaultIfEmptyをかければ、それはもうEnumerable(Of Dept)ではなくEnumerable(Of String)ですから、引数で空文字を指定することができます(エラーメッセージの「プリミティブ型か列挙型のみがサポートされています」というのは、そういうことだと思います)。

Dim query = 
	From emp In employees
	Group Join dept In departments
		On dept.deptno Equals emp.deptno
		Into tmp = Group
	From deptname In (From tmp2 In tmp Select tmp2.deptname).DefaultIfEmpty(String.Empty)
	Select emp.empname, deptname = deptname

なんだかんだでまだちょっと、LINQでSQLみたいなことをやろうとするとなかなか直感的にはいきませんが、とりあえずはできますよ、ということにて。

0
2
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?