SQL

【SQL】テーブル結合をマスターしよう: データを自在に扱う方法を解説

データベースの管理や情報処理において、SQL(Structured Query Language)は非常に重要な役割を果たしています。特に複数のテーブルを結合してデータを操作する際には、正確なクエリの作成と最適なパフォーマンスを考慮する必要があります。本記事では、SQLで複数テーブルを扱う際の効果的な方法を、問題、解決案、解説の構成で書いていきたいと思います。

SQLでは複数のテーブルを結合(join)する際に、適切な結合方法を選択することが重要です。INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL JOINなど、さまざまな結合方法がありますが、それぞれの特徴と適切な使い方を理解し使い分けることが大切です。また、JOINの条件や結合順序によってクエリの実行速度が大きく変わることもありますので、最適なパフォーマンスを得るためのヒントもご紹介します。

複数テーブルからのデータを組み合わせるための結合(join)と集合(set)演算を紹介します。結合はSQLの基礎です。集合演算も重要です。複雑なクエリを習得したければ、結合と集合演算から始める必要があります。

今回の記事では、シーケンステーブル(連番テーブル)を利用するので、下記にシーケンステーブル作成用のSQLを記載します。必要に応じて使用して下さい。

-- シーケンステーブルを作成(t1)
create table t1 (
	ID int 
);

drop procedure if exists loop_sample;
-- 区切り文字を一時的に「;」→「//」に変更する
delimiter //
-- loop_sampleというプロシージャーを作成する
create procedure loop_sample(in i int)
begin
  -- 変数cntの定義
  declare cnt int default 0;
  -- 繰り返し処理
  while cnt < i do
    set cnt = cnt + 1;
    insert into t1 values(cnt);
  end while;
end
//
-- プロシージャーを実行
call loop_sample(1);

コメントを振っておきましたが、テーブル(t1)を作成、ストアドプロシージャーを定義、定義したプロシージャーを実行の流れです。もし、PHPMyAdminなどを利用している環境でしたら、SQLタブを開き上記をコピペして実行するとシーケンステーブルが出来上がります。
シーケンステーブルやカレンダーテーブルは便利なので、作り方を覚えておくと役に立ちます。

行セットを別の行セットの上にスタックする

問題

複数テーブルに格納されているデータを、概念的にある結果セットを別の結果セットの上にスタックして返したい。テーブルは必ずしも共通キーを持っているわけではないが、同じデータ型の列を持っている。例えば、EMPテーブルの部署10の従業員の名前と部署番号を、DEPTテーブルにある部署名と部署番号を表示したい。

解決案

集合演算UNION ALLを使って、複数テーブルからの行を統合します。

select ename as ename_and_dname, deptno
   from emp
  where deptno = 10
  union all
 select '----------', null
   from t1
  union all
 select dname, deptno
   from dept;

解説

UNION ALLは複数の行ソースからの行を1つの結果セットに統合します。すべての集合演算と同様に、SELECTリストの項目は数とデータ型が一致する必要があります

UNION ALLでは重複しているものがあれば、それも含めて取得します。重複を除きたければ、UNION演算子を使います。例えば、EMP.DEPTNOとDEPT.DEPTNOにUNIONを使うと4行だけが返されます。

select deptno
  from emp
 union
select deptno
  from dept;

UNION ALLではなくUNIONを指定すると、重複を取り除くためにソートが行われます。大規模なデータを扱う際は注意して下さい。予想以上に負荷がかかるリスクがあります。UNIONは、UNION ALLからの出力にDISTINCTを適用する次のクエリとほぼ同じです。

select distinct deptno
  from (
select deptno
  from emp
 union all
select deptno
  from dept
       );

必要がない限りクエリでDISTINCTを使うことはないと思います。必要がない限りはUNION ALLを使用する方が良いです。

関連する行を組み合わせる

問題

既知の共通列を結合するか共通の値を共有する列を結合して、複数テーブルからの行を返したい。例えば、部署10のすべての従業員の名前と部署の場所を表示したいが、そのデータは2つの別々のテーブルに格納されている。

解決案

DEPTNOに基づいてEMPテーブルをDEPTテーブルに結合します。

select e.ename, d.loc
  from emp e, dept d
 where e.deptno = d.deptno
   and e.deptno = 10;

解説

この解決策は結合(join)を行うことです。(等価結合(equi-join)で、内部結合)。結合は2つのテーブルからの行を1つに統合する操作です。等価結合は、結合条件が等価条件に基づいている結合です。内部結合は結合の基本形式で、返される行には各テーブルからのデータが含まれます。

理論的には、結合による結果を作るには、次のようにまずFROM句に指定したテーブルから直積(すべての行の組み合わせ)を作成します。

select e.ename, d.loc,
       e.deptno as emp_deptno,
       d.deptno as dept_deptno
  from emp e, dept d
 where e.deptno = 10;

EMPテーブルの部署が10である全従業員とDEPTテーブルのすべての部署の組み合わせを返します。そして、WHERE句内のe.deptnoとd.deptnoに関する式で、EMP.DEPTNOとDEPT.DEPTNOが等しい行のみを返すように結果を制限しています。

select e.ename, d.loc,
       e.deptno as emp_deptno,
       d.deptno as dept_deptno
  from emp e, dept d
 where e.deptno = d.deptno
   and e.deptno = 10;

他にも明示的なJOIN句を使う方法があります。

select e.ename, d.loc
  from emp e inner join dept d
    on (e.deptno = d.deptno)
 where e.deptno = 10;

WHERE句内よりもFROM句内で結合を使う方が好みであれば、JOIN句を使ってください。どちらの形式でも、全てのDBで動作します。

2つのテーブルに共通する行を取得する

問題

2つのテーブル間で共通の行を見つけたいが、結合できる列が複数存在する。例えば、教育目的でEMPテーブルから作成した次のようなビューVがあるとする。

create view V
    as
select ename,job,sal
  from emp
 where job = 'CLERK';

ビューVからは事務員だけが返される。しかし、このビューはEMP内のすべての列を返さない。ビューVの行に一致する全従業員のEMPNO、ENAME、JOB、SAL、DEPTNOを返したい。

解決案

正しい結果を返すのに必要なすべての列に対してテーブルを結合します。

-- 複数の結合条件を使って、EMPテーブルをビューVに結合
select e.empno,e.ename,e.job,e.sal,e.deptno
  from emp e, V
 where e.ename = V.ename
   and e.job   = V.job
   and e.sal   = V.sal;

-- JOIN句を使って結合
select e.empno,e.ename,e.job,e.sal,e.deptno
  from emp e join V
    on (e.ename   = V.ename
        and e.job = V.job
        and e.sal = V.sal );

結合を実施する際には、正しい結果を返すために、結合すべき適切な列を検討する必要があります。これは、ある列の値は共通であるのに他の列の値は異なる行がありえる場合に特に重要です。

集合演算INTERSECTは、両方の行ソースに共通する行を返します。INTERSECTを使う際には、2つのテーブルの同じデータ型を持つ同じ数の項目を比較する必要があります。集合演算を使う際には、デフォルトでは重複行を返しません。

※MySQL(8.0.31)以前は、集合演算INTERSECTを使えません。上の2つのSQLの内いずれかで解決できます。

テーブルAからテーブルBには存在しない値を取得する

問題

あるテーブルA(ソーステーブルと呼ぶ)内の値で、もう一方のテーブルBには存在しない値を見つけたい。例えば、EMPテーブルには存在しない、DEPTテーブル内の部署がもし存在するなら見つけたい。

解決案

この問題には、差集合を計算する関数(EXCEPT、Oracleは minus)があると簡単ですがMySQL(8.0.31)以前はありません。
MySQL(8.0.31)以前で、この手の問題を解決するには、サブクエリ(副問い合わせ)を使います。ここでは、サブクエリの例を示します。

select deptno
  from dept
 where deptno not in (select deptno from emp);

このサブクエリは、EMPテーブルからすべてのDEPTNOを返します。外側のクエリは、このサブクエリが返す結果セットに「存在しない」か「含まれない」テーブルDEPTのすべてのDEPTNOを返します。

この問題を解決する場合、重複を削除する必要があります。どちらにしてもこのサンプルデータではDEPTNOが対象である場合のみ成り立ちます。DEPTNOが対象でない場合には、DISTINCTを次のように使えば、EMPにないDEPTNO値を一度しか含めないようにできます。

select distinct deptno
  from dept
 where deptno not in (select deptno from emp);

テーブルAからテーブルB内に対応する行がない行を取得する

問題

共通のキーを持つ2つのテーブルに対して、一方のテーブルAに存在する行のうち、もう一方のテーブルBに一致する行がない行を探したい。例えば、従業員のいない部署を探したい。
要は、DEPT内の結合条件を満たさない行だけが欲しい。

解決案

一方のテーブルのすべての行と、もう一方のテーブル内の共通列が一致する行または一致しない行を返します。そして、一致しない行だけを残します。

外部結合を使い、NULLを抽出します。

select d.*
  from dept d left outer join emp e
    on (d.deptno = e.deptno)
 where e.deptno is null;

解説

この例では、外部結合を行った後、一致しない行だけを残しています。このような操作は、反結合と呼びます。反結合の動作方法をよりよく理解するために、まずはNULLをフィルタリングせずにこの結果を検証してみると良いでしょう。次のクエリを流してみて下さい。

select e.ename, e.deptno as emp_deptno, d.*
  from dept d left join emp e
    on (d.deptno = e.deptno);

最終行は、EMP.ENAMEとEMP.DEPTNOがNULLが表示されると思います。これは、部署40では従業員が誰も所属していないからです。ここではWHRER句を使ってEMP_DEPTNOがNULLである行だけを残しています。

他の結合を妨げずにクエリに結合を追加する

追加で次のSQLを流し、テーブルを作成して下さい。

CREATE TABLE emp_bonus 
 (empno INT, received DATE, type INT);

INSERT INTO emp_bonus(empno, received, type) 
VALUES (7369, STR_TO_DATE('14-MAR-2015', '%d-%b-%Y'), 1);
INSERT INTO emp_bonus(empno, received, type)
 VALUES (7900, STR_TO_DATE('14-MAR-2015', '%d-%b-%Y'), 2);
INSERT INTO emp_bonus(empno, received, type) 
 VALUES (7788, STR_TO_DATE('14-MAR-2015', '%d-%b-%Y'), 3);

問題

期待通りの結果を返す次のクエリがある。

select e.ename, d.loc
  from emp e, dept d
 where e.deptno=d.deptno;

追加情報が必要だが、その情報を得ようとすると、元の結果セットのデータを失ってしまう。例えば、全従業員、従業員が所属する部署の場所、ボーナスを受け取った日付を返したい。
ボーナスが従業員に支給された日付をこの結果に追加したいが、EMP_BONUSテーブルに結合すると、すべての従業員にボーナスがあるわけではないので望んでいるより少ない行しか返さない。

select e.ename, d.loc,eb.received
  from emp e, dept d, emp_bonus eb
 where e.deptno=d.deptno
   and e.empno=eb.empno;

解決案

外部結合を使うと、元のクエリからのデータを失うことなく追加情報を得られます。まず、EMPとDEPTを結合して全従業員と所属する部署の場所を取得し、EMP_BONUSテーブルと外部結合してボーナス受領日があればその日付を返します。

select e.ename, d.loc, eb.received
  from emp e join dept d
    on (e.deptno=d.deptno)
  left join emp_bonus eb
    on (e.empno=eb.empno)
 order by 2;

スカラサブクエリ(SELECTリストに配置されたサブクエリ)を使って外部結合と同じ処理を実行することもできます。

select e.ename, d.loc,
       (select eb.received from emp_bonus eb
        where eb.empno=e.empno) as received
  from emp e, dept d
 where e.deptno=d.deptno
 order by 2;

解説

外部結合は、一方のテーブルのすべての行と、もう一方のテーブルの該当する行を返します。外部結合がこの問題の解決に役立つ理由は、外部結合しなかった場合に返す行を削除しないからです。このクエリは、外部結合がない場合に返すすべての行を返します。また、受領日が存在すればその日付も返します。

スカラサブクエリはメインクエリで既に正しく行われている結合を修正する必要がないため、スカラサブクエリもこのような問題に便利な手法です。スカラサブクエリは、現在の結果セットを破壊することなくクエリにデータを追加するための簡単な方法です。スカラサブクエリを使う際には、必ずスカラ(単一の)値を返すようにする必要があります。SELECTリスト内のサブクエリが複数行を返すとエラーになります。

2つのテーブルが同じデータを持つかどうかを判断する

2つのテーブルまたはビューが同じデータ(カーディナリティと値)を持つかどうかを知りたい。例えば次のようなビューがあるとする。

create view v
    as
select * from emp where deptno != 10
 union all
select * from emp where ename = 'WARD';

このビューがEMPテーブルと全く同じデータを持つかどうかを判断したい。従業員WARDの行が重複しているのは、この解決策により異なるデータだけではなく重複もわかることを示すためである。EMPテーブルの行と比べると、部署10(DEPTNO=10)の従業員の3行と従業員WARDの2行が異なる。

解決案

差集合を計算する関数を使うと、テーブル比較の問題が比較的簡単に解決できます。この関数はMySQL(8.0.31)以前は使用できません。前述のバージョン以前の場合、相関サブクエリを使います。

select *                                     
  from (                                         
select e.empno,e.ename,e.job,e.mgr,e.hiredate,   
       e.sal,e.comm,e.deptno, count(*) as cnt   
  from emp e                                   
 group by empno,ename,job,mgr,hiredate,     
         sal,comm,deptno        
       ) e                               
 where not exists (     
select null          
  from (                               
select v.empno,v.ename,v.job,v.mgr,v.hiredate,  
       v.sal,v.comm,v.deptno, count(*) as cnt 
  from v                  
 group by empno,ename,job,mgr,hiredate,     
          sal,comm,deptno                        
       ) v                                       
  where v.empno     = e.empno              
     and v.ename    = e.ename                    
     and v.job      = e.job                      
     and coalesce(v.mgr,0) = coalesce(e.mgr,0) 
     and v.hiredate = e.hiredate               
     and v.sal      = e.sal                      
     and v.deptno   = e.deptno                   
     and v.cnt      = e.cnt                      
     and coalesce(v.comm,0) = coalesce(e.comm,0)
)
   union all
  select *
    from (
  select v.empno,v.ename,v.job,v.mgr,v.hiredate,
         v.sal,v.comm,v.deptno, count(*) as cnt
    from v
  group by empno,ename,job,mgr,hiredate,
           sal,comm,deptno
        ) v
  where not exists (
 select null
   from (
 select e.empno,e.ename,e.job,e.mgr,e.hiredate,
        e.sal,e.comm,e.deptno, count(*) as cnt
   from emp e
  group by empno,ename,job,mgr,hiredate,
           sal,comm,deptno
        ) e
  where v.empno     = e.empno
    and v.ename     = e.ename
    and v.job       = e.job
    and coalesce(v.mgr,0) = coalesce(e.mgr,0)
    and v.hiredate  = e.hiredate
    and v.sal       = e.sal
    and v.deptno    = e.deptno
    and v.cnt       = e.cnt
    and coalesce(v.comm,0) = coalesce(e.comm,0)
);

相関サブクエリとUNION ALLを使い、ビューVに存在してEMPテーブルに存在しない行と、EMPテーブルに存在してビューVに存在しない行を統合して両者の違いを探します。

解説

異なる手法を使っていても、すべての解決策の考え方は同じです。

  1. ビューVに存在しないEMPテーブルの行を見つける。
  2. 見つけた行と、EMPテーブルに存在しないビューVの行を統合する(UNION ALL)。

対象となるテーブルが同じであれば行を全く返しません。テーブルが異なれば、違いを生み出している行を返します。

次のクエリはこのための簡単な例です。

select count(*)
  from emp
 union
select count(*)
  from dept;

UNIONは重複を取り除くので、テーブルのカーディナリティが同じであれば1行だけを返します。この例では2行を返しているので、テーブルに同じ行セットが含まれていないことがわかります。

カーディナリティ:リレーショナルデータベースにおいてあるテーブルの同一の列(カラム)に含まれる異なる値の数(バリエーション)のことを指すことが多い。

直積を特定して回避する

問題

部署10の各従業員の名前と部署の場所を返したい。次のクエリが返すデータは正しくない。

select e.ename, d.loc
  from emp e, dept d
 where e.deptno = 10;

解決案

FROM句内でテーブルの結合を使います。

select e.ename, d.loc
  from emp e, dept d
 where e.deptno = 10
   and d.deptno = e.deptno;

解説

部署10(DEPTNO=10)はニューヨークにあることがわかるため、ニューヨーク以外の場所を伴った従業員を返すのは間違いであるとわかります。正しくないクエリが返す行数は、FROM句の2つのテーブルのカーディナリティの積です。最初のクエリでは、EMPに対する部署10でのフィルタにより3行の結果になります。DEPTにはフィルタがないので、DEPTからは4行すべてを返します。3×4=12なので、正しくないクエリは12行を返します。一般に、直積を回避するには、n-1個のルールを適用します。ここでnはFROM句内のテーブル数を表し、n-1は直積を回避するのに必要な最小限の結合数を表します。テーブル内のキー列と結合列によっては、n-1以上の結合が必要な場合もありますが、クエリを記述する際にはn-1個の結合から始めるのがよいでしょう。

集約の使用時に結合を実行する

集約を実行したいが、クエリには複数のテーブルが含まれている。結合が集約を妨げることがないようにしたい。例えば、部署10の従業員の給与の合計とボーナスの合計を求めたいとする。一部の従業員は、複数のボーナスを受け取り、EMPテーブルとEMP_BONUSテーブルを結合すると、集約関数SUMが正しくない値を返す原因となっている。

ここで、部署10の全従業員の給与とボーナスを返す次のようなクエリを考える。EMP_BONUS.TYPEテーブルでボーナスの金額を決定する。タイプ1のボーナスは従業員の給与の10 %、タイプ2は20 %、タイプ3は30 %である。

select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type = 1 then .1
                  when eb.type = 2 then .2
                  else .3
             end as bonus
 from emp e, emp_bonus eb
where e.empno  = eb.empno
  and e.deptno = 10;

ここまでは問題ない。しかし、EMP_BONUSテーブルと結合してボーナスの金額を合計しようとすると問題が発生する。

select deptno,
       sum(sal) as total_sal,
       sum(bonus) as total_bonus
  from (
select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type = 1 then .1
                  when eb.type = 2 then .2
                  else .3
             end as bonus
  from emp e, emp_bonus eb
 where e.empno  = eb.empno
   and e.deptno = 10
       ) x
 group by deptno;

TOTAL_BONUSは正しいが、TOTAL_SALは正しくない。部署10のすべての給与の合計は、次のクエリが示すように8750が正しい。

select sum(sal) from emp where deptno=10;

正しくない原因は、この結合によってSAL列の行が重複しているためだ。ここで、EMPテーブルとEMP_BONUSテーブルを結合する次のようなクエリを考える。

select e.ename,
        e.sal
  from emp e, emp_bonus eb
 where e.empno  = eb.empno
   and e.deptno = 10;

このクエリを実行するとTOTAL_SALの値がなぜ正しくないかわかる。給与が2回カウントされているから従業員がいるからだ。

解決案

結合の使用時に集約を計算する場合は注意が必要です。一般的に、結合が重複行を返す場合、2通りの方法で集約関数による計算間違いを回避できます。集約関数の呼び出しにDISTINCT句を使うだけで、各値の一意の結果だけが計算に使われます。あるいは、結合の前に(インラインビュー内で)まず集約を実行することで、結合前に既に集約が計算されることになり、集約関数による誤った計算を防ぎ、この問題を完全に回避できます。

-- distinct を使う方法
select deptno,
       sum(distinct sal) as total_sal,
       sum(bonus) as total_bonus
  from (
select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type = 1 then .1
                  when eb.type = 2 then .2
                  else .3
              end as bonus
  from emp e, emp_bonus eb
 where e.empno = eb.empno
   and e.deptno = 10
       ) x
 group by deptno;

解説

このレシピの「問題」の2番目のクエリは、EMPテーブルとEMP_BONUSテーブルを結合して従業員MILLERの2行を返しますが、これでは給与が2回加算されてしまい、EMP.SALの合計の間違いの原因となります。解決策は、クエリから返される一意のEMP.SAL値を合計するだけです。次のクエリは、合計する列に重複値がある場合に必要となる代替の解決策です。部署10のすべての給与の合計をまず計算し、その行をEMPテーブルと結合し、その後にEMP_BONUSテーブルと結合します。

-- インラインビューで解決する方法
select d.deptno,
       d.total_sal,
       sum(e.sal*case when eb.type = 1 then .1
                      when eb.type = 2 then .2
                      else .3 end) as total_bonus
  from emp e,
       emp_bonus eb,
       (
select deptno, sum(sal) as total_sal
  from emp
 where deptno = 10
 group by deptno
        ) d
 where e.deptno = d.deptno
   and e.empno = eb.empno
 group by d.deptno,d.total_sal;

集約の使用時に外部結合を実行する

部署10のすべての従業員がボーナスをもらうわけではないようにEMP_BONUSテーブルを変更する。ここでEMP_BONUSテーブルと、部署10の給与の合計と部署10の全従業員のボーナスの合計の両方を求める次のようなクエリを考える。

select deptno,
       sum(sal) as total_sal,
       sum(bonus) as total_bonus
  from (
select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type = 1 then .1
                  when eb.type = 2 then .2
                  else .3 end as bonus
  from emp e, emp_bonus eb
 where e.empno  = eb.empno
   and e.deptno = 10
       )
 group by deptno;

TOTAL_BONUSの結果は正しいが、TOTAL_SALで返された値は部署10の給与の合計を表していない。次のクエリは、TOTAL_SALがなぜ正しくないかを示している。

select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type = 1 then .1
                  when eb.type = 2 then .2
             else .3 end as bonus
  from emp e, emp_bonus eb
 where e.empno = eb.empno
   and e.deptno = 10;

部署10のすべての給与を合計するのではなく、一人の従業員の給与だけを合計している。さらに間違って2回合計していることがわかる。

解決案

EMP_BONUSに外部結合し、部署10の全従業員が必ず含まれるようにします。
EMP_BONUSに外部結合し、部署10の重複しない給与だけを合計します。

select deptno,
       sum(distinct sal) as total_sal,
       sum(bonus) as total_bonus
  from (
select e.empno,
       e.ename,
       e.sal,
       e.deptno,
       e.sal*case when eb.type is null then 0
                  when eb.type = 1 then .1
                  when eb.type = 2 then .2
                  else .3 end as bonus
  from emp e left outer join emp_bonus eb
    on (e.empno = eb.empno)
 where e.deptno = 10
       )
 group by deptno;

ウィンドウ関数SUM OVERを使うことで解決する方法もあります。

select distinct deptno,total_sal,total_bonus
  from (
select e.empno,
       e.ename,
       sum(distinct e.sal) over
       (partition by e.deptno) as total_sal,
       e.deptno,
       sum(e.sal*case when eb.type is null then 0
                      when eb.type = 1 then .1
                      when eb.type = 2 then .2
                      else .3
                 end) over
       (partition by deptno) as total_bonus
  from emp e left outer join emp_bonus eb
    on (e.empno = eb.empno)
 where e.deptno = 10
       ) x;

解説

「問題」の2番目のクエリは、EMPテーブルとEMP_BONUSテーブルを結合し、従業員MILLERの行だけを返しています。これがEMP.SALの合計の間違いの原因です。部署10の他の従業員はボーナスをもらっておらず、彼らの給与が合計に含まれていません。解決策ではEMPテーブルをEMP_BONUSテーブルに外部結合しているため、ボーナスのない従業員も結果に含まれます。従業員にボーナスがなければ、EMP_BONUS.TYPEにはNULLが返されます。EMP_BONUS.TYPEがNULLの場合は、CASE式がゼロを返し、合計には影響を与えません。

次のクエリは別の解決策です。部署10の給与の合計をまず計算し、EMPテーブルに結合します。そしてそれをEMP_BONUSテーブルに結合します。

select d.deptno,
       d.total_sal,
       sum(e.sal*case when eb.type = 1 then .1
                      when eb.type = 2 then .2
                      else .3 end) as total_bonus
  from emp e,
       emp_bonus eb,
       (
select deptno, sum(sal) as total_sal
  from emp
 where deptno = 10
 group by deptno
       ) d
 where e.deptno = d.deptno
   and e.empno = eb.empno
 group by d.deptno,d.total_sal;

複数テーブルから欠損データを返す

問題

複数テーブルから欠損データを同時に返したい。EMPテーブルに存在しないDEPTテーブルの行(従業員のいないすべての部署)を返すには、外部結合が必要である。例えば、DEPTのすべてのDEPTNOとDNAMEを、(ある特定の部署に従業員が存在すれば)各部署の全従業員の名前と共に返すような次のクエリを考える。

select d.deptno,d.dname,e.ename
  from dept d left outer join emp e
    on (d.deptno=e.deptno);

EMPテーブルをDEPTテーブルに外部結合しているため、最後の行の部署OPERATIONSは、従業員がいないにもかかわらず返される。ここで部署がない従業員がいたとする。上の結果セットと部署がない従業員の行を返すにはどのようにするのだろうか。つまり、同じクエリ内でEMPテーブルとDEPTテーブルの両方に外部結合したい。新しい従業員を作成した後に最初に行うクエリは、次のようなものだろう。

insert into emp (empno,ename,job,mgr,hiredate,sal,comm,deptno)
select 1111,'YODA','JEDI',null,hiredate,sal,comm,null
  from emp
 where ename = 'KING';

select d.deptno,d.dname,e.ename
  from dept d right outer join emp e
    on (d.deptno=e.deptno);

この外部結合は新しい従業員を返しはするものの、元の結果セットの部署OPERATIONSがなくなってしまっている。最終的な結果セットは、次のようにYODAとOPERATIONSの行を返す必要がある。

解決案

完全外部結合を使って、両方のテーブルから共通の値に基づいて欠損データを返します。
MySQLにはFULL OUTER JOINがまだないため、2つの異なる外部結合の結果を統合(UNION)します

select d.deptno,d.dname,e.ename
  from dept d right outer join emp e
    on (d.deptno=e.deptno)
 union
select d.deptno,d.dname,e.ename
  from dept d left outer join emp e
    on (d.deptno=e.deptno);

解説

完全外部結合は、単に両方のテーブルに対する外部結合の組み合わせです。完全外部結合の「内部的な」動作方法を知るには、それぞれの外部結合を実行し、その結果を統合するだけです。

演算や比較でNULLを使う

問題

NULLはどの値(NULL自身にさえ)に等しくなることも等しくならないこともないが、NULLを許容する列が返す値を、実際の値を評価するのと同様に評価したい。例えば、EMP内で歩合給(COMM)が従業員WARDの歩合給より少ない従業員をすべて見つけたい。歩合給がNULLの従業員も含めなければいけない。

解決案

COALESCEなどの関数を使って、NULLを標準的な評価に使える実際の値に変換します。

select ename,comm
  from emp
 where coalesce(comm,0) < (select comm
                           from emp
                           where ename = 'WARD');

解説

COALESCE関数は、渡された値のリストから最初の非NULLを返します。NULLがあると、ゼロに置換してからWARDの歩合給と比較します。これは、SELECTリストにCOALESCE関数を指定すれば確認できます。

select ename,comm,coalesce(comm,0)
  from emp
 where coalesce(comm,0) < (select comm
                             from emp
                            where ename = 'WARD');

まとめ

結合は、データベースの問い合わせに不可欠です。データベースへの問い合わせを行う際に、2つ以上のテーブルを結合しなけなければいけないのが一般的です。この記事で説明した結合のさまざまな組み合わせや種類を理解することが出来れば問題ないでしょう。

入門書籍や解説を行なっているサイトなどで見ると、SELECT文の延長で簡単な解説が殆どで実務的な問題に対応できないケースが非常に多いです。

SQLの基本はSELECT文です。SELECT文の使い方をしっかり理解することで、DB設計を行う際にアンチパターンを事前に防ぐ事もできますし、安定したパフォーマンスで稼動するアプリケーションを作成することもできます。