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句版)
そこで、結合条件を追加します。
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件以上存在する場合、自動的に絞り込みつつ対応付けてくれます。
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#でクエリ式の文法を変えてしまったんでしょうかね?)。
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句で、値を取り出します。
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の取得等)に、例外を発生させます。
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みたいなことをやろうとするとなかなか直感的にはいきませんが、とりあえずはできますよ、ということにて。