【MySQL】外部キー制約を満たしていないレコードがあるかチェックするプロシージャ

  • 2025年10月16日
  • SQL

 やんごとなき理由で外部キー制約を外した状態のデータベースを操作する場合があります。また外部キー制約がなかったところに外部キー制約を追加することがあります。そういった作業をした後は外部キー制約が正しく守られているかチェックしたくなります。このチェックはチェック対象が少ない――例えば『投稿の投稿者アカウント』に関する外部キー制約のみを調査するという話なら簡単です。これだけなら次のSQLを実行して結果件数を見るだけでOKです。

SELECT
  COUNT(*) AS 対応する投稿者アカウントが見つからなかった投稿数
FROM posts
-- 目的の外部キー制約でLEFT JOINすることにより、author_id カラムに値はあるが対応する users.user_id が存在しない行を取得対象に含められる
LEFT JOIN users ON posts.author_id = users.user_id
WHERE
    posts.author_id IS NOT NULL  -- 投稿者アカウントIDがNULLの投稿は調査対象外
  AND users.user_id IS NULL      -- 対応する投稿者アカウントが存在しない

 調査対象が少なければ人力でこれを改造したSQLを複数回実行するだけでいいのですが、とても人力でやっていられないぐらい対象が多い時もあります。そういった時は外部キー制約情報を基にSQLを組み立て、そのSQLを実行する、というストアドプロシージャを使った方がいいです。このストアドプロシージャは例えば次のように作れます。

 コメントに出てくる親は参照先、子は参照元、孤児は参照先を持たない参照元です。先ほどの投稿の例でいえば、親が users、子が posts、孤児が posts.author_id に対応する users.user_id が存在しない posts のレコードです。

DELIMITER $$

# 作り直す時用の必要な時だけ削除処理
DROP PROCEDURE IF EXISTS check_fk_integrity $$
# 本体
CREATE PROCEDURE check_fk_integrity(IN in_schema VARCHAR(64))
BEGIN
    -- =====================================================
    -- ① 変数・ハンドラ・カーソル定義
    -- =====================================================

    -- ループ終了検知用フラグ
    DECLARE done INT DEFAULT 0;

    -- カーソルから受け取る各種メタ情報(制約・子表・親表)
    DECLARE v_constraint_schema VARCHAR(64);   -- 制約が属するスキーマ
    DECLARE v_constraint_name   VARCHAR(128);  -- 制約名(FK名)
    DECLARE v_child_schema      VARCHAR(64);   -- 子テーブルのスキーマ
    DECLARE v_child_table       VARCHAR(64);   -- 子テーブル名
    DECLARE v_parent_schema     VARCHAR(64);   -- 親テーブルのスキーマ
    DECLARE v_parent_table      VARCHAR(64);   -- 親テーブル名

    -- -----------------------------------------------------
    -- スキーマ内の全FK(制約単位)を列挙するカーソル
    --   - REFERENTIAL_CONSTRAINTS: FK制約の親情報
    --   - KEY_COLUMN_USAGE: FKを構成する列対応(子列→親列)
    -- GROUP BYで「制約単位」にまとめる
    -- -----------------------------------------------------
    DECLARE fk_cur CURSOR FOR
        SELECT
            rc.CONSTRAINT_SCHEMA,
            rc.CONSTRAINT_NAME,
            kcu.TABLE_SCHEMA            AS child_schema,
            kcu.TABLE_NAME              AS child_table,
            kcu.REFERENCED_TABLE_SCHEMA AS parent_schema,
            kcu.REFERENCED_TABLE_NAME   AS parent_table
        FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
        JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
          ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
         AND rc.CONSTRAINT_NAME   = kcu.CONSTRAINT_NAME
        WHERE rc.CONSTRAINT_SCHEMA = in_schema
        GROUP BY
            rc.CONSTRAINT_SCHEMA, rc.CONSTRAINT_NAME,
            child_schema, child_table, parent_schema, parent_table
        ORDER BY child_table, rc.CONSTRAINT_NAME;

    -- カーソルFETCHで結果が枯渇したらdone=1にするハンドラ
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;

    -- =====================================================
    -- ② 準備処理(セッション設定・一時テーブル作成)
    -- =====================================================

    -- GROUP_CONCATの最大長を拡張(複数列FKでも長い式を安全に組めるように)
    SET @@SESSION.group_concat_max_len = 1024 * 1024;

    -- 結果を貯める一時テーブル(TEXTを扱うのでInnoDBのテンポラリを明示)
    CREATE TEMPORARY TABLE IF NOT EXISTS fk_bad_only
    (
        constraint_schema VARCHAR(64),   -- 制約スキーマ
        constraint_name   VARCHAR(128),  -- 制約名(FK)
        child_schema      VARCHAR(64),   -- 子スキーマ
        child_table       VARCHAR(64),   -- 子テーブル
        parent_schema     VARCHAR(64),   -- 親スキーマ
        parent_table      VARCHAR(64),   -- 親テーブル
        orphans           BIGINT,        -- 孤児件数
        example_sql       TEXT           -- 孤児抽出の具体的なSQL(サンプル)
    ) ENGINE = InnoDB;

    -- =====================================================
    -- ③ 全FKを走査して孤児を数えるメインループ
    -- =====================================================

    OPEN fk_cur;

    fk_loop:
    LOOP
        -- 1件分のFKメタを取得
        FETCH fk_cur INTO
            v_constraint_schema, v_constraint_name,
            v_child_schema,      v_child_table,
            v_parent_schema,     v_parent_table;

        -- 終了判定
        IF done = 1 THEN
            LEAVE fk_loop;
        END IF;

        -- -------------------------------------------------
        -- ③-1 子↔親の列対応から3種類の条件文字列を組み立てる
        --     a) JOIN条件   : c.child_col = p.parent_col AND ...
        --     b) 子の非NULL : c.child_col IS NOT NULL AND ...
        --     c) 親キー先頭: 親側先頭列名(NULLチェック対象カラムの決定用)
        --   ※複合FKを想定しPOSITION_IN_UNIQUE_CONSTRAINT順で整列
        -- -------------------------------------------------
        SET @join_cond        := NULL;
        SET @nonnull_cond     := NULL;
        SET @parent_first_id  := NULL;

        SELECT
            -- JOIN条件(子列=親列)をAND連結
            GROUP_CONCAT(
                CONCAT('c.`', kcu.COLUMN_NAME, '` = p.`', kcu.REFERENCED_COLUMN_NAME, '`')
                ORDER BY kcu.POSITION_IN_UNIQUE_CONSTRAINT
                SEPARATOR ' AND '
            ) AS join_cond,

            -- 子列の非NULL条件をAND連結(NULLはFK未設定扱いなので除外)
            GROUP_CONCAT(
                CONCAT('c.`', kcu.COLUMN_NAME, '` IS NOT NULL')
                ORDER BY kcu.POSITION_IN_UNIQUE_CONSTRAINT
                SEPARATOR ' AND '
            ) AS nonnull_cond,

            -- 親側の先頭カラム名(LEFT JOIN後のNULL判定に使う)
            SUBSTRING_INDEX(
                GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`')
                             ORDER BY kcu.POSITION_IN_UNIQUE_CONSTRAINT SEPARATOR ','),
                ',', 1
            ) AS parent_first_id
        INTO @join_cond, @nonnull_cond, @parent_first_id
        FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
        WHERE kcu.CONSTRAINT_SCHEMA = v_constraint_schema
          AND kcu.CONSTRAINT_NAME   = v_constraint_name
          AND kcu.TABLE_SCHEMA      = v_child_schema
          AND kcu.TABLE_NAME        = v_child_table;

        -- 列対応がうまく取れない(理論上ありえないが安全側)場合は次へ
        IF @join_cond IS NULL OR @nonnull_cond IS NULL OR @parent_first_id IS NULL THEN
            ITERATE fk_loop;
        END IF;

        -- -------------------------------------------------
        -- ③-2 孤児件数の算出(動的SQL)
        --   ・子←親をLEFT JOINし、親側の先頭キーがNULLの行が「孤児」
        --   ・子キーがNULLの行はFK未設定なので除外(@nonnull_cond)
        --   ・EXECUTE ... INTOは不可のため、SQL内部でINTO @cntを記述
        -- -------------------------------------------------
        SET @cnt := 0;

        SET @count_sql = CONCAT(
            'SELECT COUNT(*) INTO @cnt ',
            'FROM `', v_child_schema,  '`.`', v_child_table,  '` c ',
            'LEFT JOIN `', v_parent_schema, '`.`', v_parent_table, '` p ',
            'ON (', @join_cond, ') ',
            'WHERE p.', @parent_first_id, ' IS NULL ',
            'AND (', @nonnull_cond, ')'
        );

        PREPARE s1 FROM @count_sql;
        EXECUTE s1;           -- ← 動的SQL内で「INTO @cnt」済み
        DEALLOCATE PREPARE s1;

        -- -------------------------------------------------
        -- ③-3 孤児が存在する場合のみ、結果テーブルに積む
        --       併せて100件だけ中身を確認できるサンプルSQLも作る
        -- -------------------------------------------------
        IF COALESCE(@cnt, 0) > 0 THEN
            SET @example_sql = CONCAT(
                'SELECT c.* ',
                'FROM `', v_child_schema,  '`.`', v_child_table,  '` c ',
                'LEFT JOIN `', v_parent_schema, '`.`', v_parent_table, '` p ',
                'ON (', @join_cond, ') ',
                'WHERE p.', @parent_first_id, ' IS NULL ',
                'AND (', @nonnull_cond, ') ',
                'LIMIT 100;'
            );

            INSERT INTO fk_bad_only
                (constraint_schema, constraint_name,
                 child_schema, child_table,
                 parent_schema, parent_table,
                 orphans, example_sql)
            VALUES
                (v_constraint_schema, v_constraint_name,
                 v_child_schema, v_child_table,
                 v_parent_schema, v_parent_table,
                 @cnt, @example_sql);
        END IF;

    END LOOP;

    -- カーソルを閉じる
    CLOSE fk_cur;

    -- =====================================================
    -- ④ 最終出力と後片付け
    -- =====================================================

    -- 孤児の多い順に並べて返す(次点で表・制約名)
    SELECT *
    FROM fk_bad_only
    ORDER BY orphans DESC, constraint_schema, child_table, constraint_name;

    -- 一時テーブルを破棄
    DROP TEMPORARY TABLE IF EXISTS fk_bad_only;
END $$

DELIMITER ;

-- 使用例
CALL check_fk_integrity('test_db');

 使用例の結果例が次表です。scenes というテーブルを親とする scene_attach_files テーブルに外部キー制約を満たしていないレコードがある場合の例です。example_sqlにあるSQLを実行すれば、より具体的な異常がわかります。

constraint_schema constraint_name child_schema child_table parent_schema parent_table orphans example_sql
test_db scenes_and_file_foreign test_db scene_attach_files test_db scenes 1 SELECT c.* FROM `test_db`.`scene_attach_files` c LEFT JOIN `test_db`.`scenes` p ON (c.`scene_id` = p.`scene_id`) WHERE p.`scene_id` IS NULL AND (c.`scene_id` IS NOT NULL) LIMIT 100;

 こんな感じで外部キー制約が守られているか後付けでチェックできます。余談ですが、上記ストアドの作成はかなりLLMに頼ってます。コンテキストを工夫しなくとも十全に指示できるような作業は任せやすいですね。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG