ON句に書くのは結合条件であって絞り込み条件ではないことに注意しよう

令和3年秋データベーススペシャリスト試験 午前Ⅱ問8にJOINのに関する問題が出題されていました。

問題

出題されているテーブルと登録されているデータは以下のとおりです。

CREATE TABLE 社員取得資格 (
    社員コード VARCHAR(4) NOT NULL,
    資格 VARCHAR(2)
);

INSERT INTO 社員取得資格 VALUES ('S001', 'FE');
INSERT INTO 社員取得資格 VALUES ('S001', 'AP');
INSERT INTO 社員取得資格 VALUES ('S001', 'DB');
INSERT INTO 社員取得資格 VALUES ('S002', 'FE');
INSERT INTO 社員取得資格 VALUES ('S002', 'SM');
INSERT INTO 社員取得資格 VALUES ('S003', 'FE');
INSERT INTO 社員取得資格 VALUES ('S004', 'AP');
INSERT INTO 社員取得資格 VALUES ('S005', NULL);

INSERTした結果は以下のようになります。問題に記載されている範囲ではPKを持たないテーブルになります。

社員コード資格
S001FE
S001AP
S001DB
S002FE
S002SM
S003FE
S004AP
S005NULL

出題されている問題は、以下の結果が得られるようにするために、

社員コード資格1資格2
S001FEAP
S002FENULL
S003FENULL

[ a ]に入る字句を答える問題になります。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
[ a ]

この問題の正解は、

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE' AND C2.資格 = 'AP'
WHERE
    C1.資格 = 'FE'

になりますが、不正解の選択肢と共通している字句は

    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE' AND C2.資格 = 'AP'

で、自己結合のために使用するC1.社員コード = C2.社員コード以外にC1.資格 = 'FE' AND C2.資格 = 'AP'という条件があるようです。

ON句の条件が結果にどのように影響するのか

ON句に結合条件以外を指定するとどのように振る舞うのかを確認してみたいと思います。

自己結合すると何が得られるのか

まずは、社員コードで自己結合すると何が得られるかを確認してみましょう。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード

このSQLを実行すると、以下のような結果が得られます。

社員コード資格1資格2
S001FEDB
S001FEAP
S001FEFE
S001APDB
S001APAP
S001APFE
S001DBDB
S001DBAP
S001DBFE
S002FESM
S002FEFE
S002SMSM
S002SMFE
S003FEFE
S004APAP
S005NULLNULL

当然ですが、同じ社員コードのレコード同士のすべての掛け合わせが表示されます。この中には自分自身のレコードも含まれています。

ON句にC1.資格 = ‘FE’を指定するとどうなるのか

ON句には結合条件を指定するのですが、ここにC1.資格 = 'FE'を指定するとどうなるのでしょうか?

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'

C1のうち資格 = 'FE'のみ結合できるようにする条件となるため、FE以外の資格の行は社員コードが一致しても結合されなくなります。

社員コード資格1資格2
S001FEDB
S001FEAP
S001FEFE
S001APNULL
S001DBNULL
S002FESM
S002FEFE
S002SMNULL
S003FEFE
S004APNULL
S005NULLNULL

資格1がFEの行については資格2にすべての組み合わせが設定されていますが、資格1がFE以外の行については資格2がNULLになっていることがわかります。

ONにC2.資格 = ‘AP’を指定するとどうなるのか

一旦、C1.資格 = 'FE'の条件を外して、C2.資格 = 'AP'の条件を指定するとどうなるでしょうか?

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C2.資格 = 'AP'

C2のうち、資格='AP'のみ結合できるようにする条件となるため、AP以外の資格の行は社員コードが一致しても結合されなくなります。結合は左外部結合(LEFT OUTER JOIN)で行われているため、先ほどと異なり、結合相手のC2の行がない資格1の行は1行だけ残り、資格2はNULLになります。

社員コード資格1資格2
S001FEAP
S001APAP
S001DBAP
S002FENULL
S002SMNULL
S003FENULL
S004APAP
S005NULLNULL

APを資格として保有している社員のみ資格1と資格2(AP固定)が表示され、APを資格として保有していない社員は資格1のみ表示されます。

ON句にC1.資格 = ‘FE’ AND C2.資格 = ‘AP’の両方を指定するとどうなるのか

これまでの内容を踏まえると、あくまでも結合対象となる条件を指定しているだけですので、社員コードが一致するC1.資格 = 'FE'の行とC2.資格 = 'AP'が結合されます。このとき、左外部結合(LEFT OUTER JOIN)で結合しているため、C1の行は最低1行は残ります。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'

C1の資格がFEでない行はC2が結合されず、C2APでなければC1FEでも結合されないため、資格1がFEでない行は資格2はNULLになり、資格2がAPになっている行は資格1がFEになります。

社員コード資格1資格2
S001FEAP
S001APNULL
S001DBNULL
S002FENULL
S002SMNULL
S003FENULL
S004APNULL
S005NULLNULL

WHERE句でC1.資格 = ‘FE’の行だけフィルタする必要がある 

ここまで見てみると資格1にはFE以外の行があることがわかります。これは左外部結合(LEFT OUTER JOIN)で結合したことでC1の行が消えないためです。得られるべき結果の資格1はFEのみになっているため、FE以外を除外するようにフィルタする必要があります。

これを実現するにはC1.資格 = 'FE'の条件をWHERE句に追加することで実現します。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'
WHERE
    C1.資格 = 'FE'
社員コード資格1資格2
S001FEAP
S002FENULL
S003FENULL

代替案の検討

INNER JOINに変更すればWHERE句は不要か

左外部結合(LEFT OUTER JOIN)によって余計な行が残るという話をしてきましたが、内部結合(INNER JOIN)に変更したらどうなるでしょうか?

WHERE句を含まないINNER JOINで自己結合したSQLを作成して実行してみます。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    INNER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'

この条件ではFEAPの両方の資格を保有している社員の行のみが表示されるようになります。

社員コード資格1資格2
S001FEAP

結合しないで同じ結果を得ることは可能

左外部結合を使った場合は、FEの資格を保有している社員を表示し、APの資格も保有していたら表示する、という条件をあらわしています。

実務でこのようなSQLがあるかといわれるとそれほど多くないと思いますが、私が書くなら結合せずに書くと思います。なぜなら、ON句内に結合条件以外の条件を書くことは、可読性が高くなく、誤った解釈をするリスクがあるためで、ON句には2つのテーブル結合するためのキー情報のみ書きたいと考えているからです。絞り込み条件に見える条件は極力WHERE句に書くようにすることで、SQLの初心者であっても読み間違えないようにすることが重要だと考えています。

では、どうするかというと結合せずにCASE句を使って資格2の条件を記載します。

SELECT
    C1.社員コード,
    C1.資格 AS 資格1,
    CASE
        WHEN EXISTS (SELECT 1 FROM 社員取得資格 C2 WHERE C1.社員コード = C2.社員コード AND C2.資格 = 'AP') THEN 'AP'
        ELSE NULL
    END AS 資格2
FROM
    社員取得資格 C1
WHERE
    C1.資格 = 'FE'

このSQLでは必須となるFEを保有している社員を単純に抽出し、資格2には同社員がAPを保有していれば'AP'と出力し、保有していなければNULLを出力するようにしています。

あくまでもFEを保有している社員を抽出することが主眼とし、+αの情報としてAPを保有しているかを抽出するようにSQLを書いています。

まとめ

ON句にキーの結合条件以外の条件が出てくるとドキッとしますが、ひとつひとつ丁寧に確認していくことでそれが結合する条件であることがわかってきます。

試験対策としてこのような動作は実機で確認して理解しておくことは重要ですが、実務においては誤解されないかを十分に確認した上で使った方がよいと思います。ON句で書くこととWHERE句で書くことは等価な場合もありますが、等価でない場合もありますので、意図した結果を得られるのかを十分に検証してください。

[SQL]INSERT SELECTでテーブルから直接INSERTする

先日投稿した記事のテーブルを使って、商品別売上実績のレコードが必ず存在するようにしてみます。

月末処理で売上平均金額を求める前に売上のない商品について0円の商品別売上実績レコードを作成する状況を想定しています。

ここではSELECTした結果をINSERTするINSERT SELECTを使ってデータを登録します。

INSERT SELECTとは

INSERT SELECTとは、INSERTするデータをSELECTで作成する手法のことです。例えば、テーブルAにテーブルBから抽出したレコードをそのままINSERTする場合、以下のように書きます。

INSERT INTO A
SELECT
  *
FROM
  B

この例では、テーブルAとテーブルBは同じカラムを持っており、テーブルBから全件抽出してそのままテーブルAにINSERTしています。このやり方はバックアップ対象と同じカラムを持つバックアップ用のテーブルを作成して、そこにテックアップ対象のレコードを全件抽出しておく、というときによく使います。

SELECT時に対象のカラムと行を絞り込む

今回実現したいのは、SELECTするテーブルとINSERTするテーブルのカラムは異なっています。これはSELECTするカラムをINSERTするカラムに合わせることで対応します。

もう一つ、すべてのカラムを抽出するのではなく、すでにINSERTするテーブルに存在している商品コードはINSERTする必要がない(INSERTすると一意制約違反となる)ため、商品テーブルにあって商品別売上実績テーブルにない商品コードだけINSERTするように対象レコードを絞り込みます。

作成したSQLは以下のようになります。

INSERT INTO sales_by_product
SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

INSERTするテーブルのカラムと同じカラムを抽出する

SELECTではINSERTするテーブルと同じカラムを抽出します。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1

INSERT先の商品別売上実績テーブルはproduct_codetotal_sales_amountを持っているため、これらのカラムだけになるように抽出しています。product_codeproductsテーブルのカラムを使用し、total_sales_amountは今回やりたいことに合わせてデフォルト値の0を設定するようにしています。0 total_sales_amountのように固定値を直接記述することができるかどうかは使用しているRDBMSによって異なりますので注意してください。

INSERTするテーブルに存在していないレコードのみ抽出する

少しややこしいですが、NOT EXISTSを使ってsales_by_productに存在しない商品コードのみ抽出するようにします。

FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

SELECTしているproducts T1NOT EXISTS内のSELECT文のsales_by_product T2product_codeで結合することで、T1T2双方に存在するsales_by_productが抽出できます。ということは、T1NOT EXISTS以外に絞り込み条件を持たないので、すべてのT1のレコード(すべての商品)について、それぞれのレコードに対応するT2(商品別売上実績)を抽出してみて、それが存在しなかったら(NOT EXISTS)、SELECTされるということになります。

NOT EXISTS内のSELECTしている列が1となっていますが、これは行が抽出できていること自体にしか意味がないので、無駄なデータを抽出しないために適当な1という値を指定しているだけです。'x'とか3とかでも問題ありませんし、データ量のことを気にしないのであれば*でも構いません。

INSERTする前にSELECTだけを実行してみる

実際にINSERTをする前にINSERT部分以外のSELECT文単独で実行結果を確認しておきます。実務においてもまずはINSERTしたい形と同じかをSELECTだけで確認し、それが確認できたらINSERTするのが安全です。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

まずは、INSERT前の商品別売上実績テーブルを確認してみます。

SELECT * FROM sales_by_product;

=>
S001,50
S003,250
S004,350
S006,450

商品コードS002S005が欠損していることがわかります。これが売上合計金額0で抽出できればINSERTしたい内容と一致します。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

=>
S002,0
S005,0

問題なさそうです。

では実際にINSERTまでやって、再度全件抽出してみます。

INSERT INTO sales_by_product
SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

SELECT * FROM sales_by_product;

=>
S001,50
S002,0
S003,250
S004,350
S005,0
S006,450

商品コードS002S005が売上合計金額0で登録されていることが確認できました。

トランザクション機能を使って確認後にコミットする

今回は自動トランザクションでINSERTしたら自動コミットされるようにしています。

実務では手動トランザクションで明示的にコミットするまではDBにコミットされないようにしておくことで、INSERT後の確認でOKになるまでは作業のやり直しができるようにしておくことが重要です。

まとめ

INSERT SELECTを使ってあるテーブルから直接INSERTする方法について確認しました。バッチなどでのバルクインサートやテーブルのバックアップ・リストアなどINSERT SELECTを行うシーンは意外とあります。

INSERTしたい結果を抽出できるSELECT文を書いて、その前にINSERT INTO テーブル名を付けるということを覚えておけば、それほど悩むこともないと思います。細かい点を抜きにすればUPDATE SELECTとは異なり、RDBMSによって構文が変わるということもないので、どれかのRDBMSでやり方を覚えておけば他のRDBMSでもほぼそのまま適用できます。

モバイルバージョンを終了