SQL レビューを効率化する CI。CADDiにおける実践知

この記事は dbt Advent Calendar 2025 の16日目の記事です。

Data Management チームの森岡です。要らなくなったものをすぐに捨てられるデータ基盤を意識して日々開発しています。

この記事では、CADDi における SQL レビューを効率化するための CI の実践知について紹介します。

はじめに

生成 AI の台頭により、データエンジニアリングは大きく変わりつつあります。

CADDi では、AI エージェント「Devin」を Slack ワークフローに組み込むことで、Biz 職のメンバーでも自律的に dbt model の作成・修正が行える環境を構築しています。 この取り組みによって開発速度は大きく向上しましたが、その一方で週30〜50件のPRという大量のレビュー負荷が発生しました。

SQL の作成コストがほぼゼロに近づく中で、ボトルネックは「実装」から「レビュー」へと移っています。 そのため現在の重要な課題は、いかに安全かつ高速にレビューを完了させるかです。

SQL レビューの辛さ

SQL レビューにおいて、レビュアーの時間を奪う主な要因は次のような確認作業です。

  1. 実行可否の確認 (脳内コンパイルの限界):
    dbt model は Jinja を含むため、パッと見だけでは実行可能な SQL か判断できません。正確に確認するにはローカルでコンパイルを通す必要があり、地味に時間がかかります。

  2. コーディングルールの遵守確認:
    一貫性と保守性を保つため、さまざまな命名規則やコーディングルールが存在します。これらを毎回手動で確認するのは負担が大きく、レビュアーごとにチェック粒度が異なる「属人化」も起きがちです。

  3. SQL ロジックの確認:
    SQLの構文が正しくても、作成者が意図した「ビジネスロジックとして正しいデータが出力されているか」の検証には時間がかかります。

  4. 他の dbt model への影響調査:
    一つのモデル変更が下流にどう波及するか、毎回リネージグラフを辿って確認するのは骨の折れる作業です。特にモデル数が多いと、安全確認だけでかなりの時間を要します。

これらの課題を解決するため、CADDi では dbt の CI を整備しています。

開発フローの全体像

※ エンジニアは、必ずしも Devin を経由する必要はなく、各自が好きな方法でPRを作成しています。以下は、簡単のため Devin を使った場合の開発フローを示したシーケンス図です。

sequenceDiagram
    actor 依頼者
    participant Devin as Slackワークフロー(Devin)
    participant GitHub
    participant CI as GitHub Actions
    participant BQ_tmp as BigQuery(一時データセット)
    actor レビュアー
    participant BQ_prod as BigQuery(stg データセット)

    依頼者->>Devin: Slackワークフローで<br/>dbt model作成/修正依頼
    Devin->>GitHub: PR作成

    GitHub->>CI: CI自動実行
    CI->>CI: 変更されたmodelを検出
    CI->>BQ_tmp: dbt build実行<br/>一時データセット作成<br/>(tmp_PR_XXX)
    CI->>GitHub: 「コンパイル済みSQL」と「一時データセットのリンク」をコメント
    CI->>依頼者: ビルド完了通知


    依頼者->>BQ_tmp: 一時データセットでデータ確認

    opt 修正が必要な場合
        依頼者->>Devin: 確認・追加指示
        Devin->>GitHub: PR更新
        GitHub->>CI: CI再実行
    end

    レビュアー->>GitHub: PRレビュー
    レビュアー->>GitHub: Approve & Merge

    GitHub->>BQ_prod: stg データセットにデプロイ

    Note over BQ_tmp: 7日後
    BQ_tmp->>BQ_tmp: 一時データセット自動削除

CADDi の dbt CI の構成

ここからは、先ほどのシーケンス図に沿って、各 CI コンポーネントの役割を説明します。

ステップ1: PR作成(Devin による自動生成)

依頼者がSlackワークフローを起動すると、Devin AIが対話的にdbt modelを作成・修正し、GitHub PRを自動作成します。これにより、Biz職でも dbt model の変更が可能になります。

Slack ワークフローの起動画面

ステップ2: PR データセットのビルド

対応する課題: ① 実行可否の確認、③ SQLロジックの確認

GitHub でPRが作成されると、CI (GitHub Actions) が自動的に起動し、変更分のみを一時データセット (tmp_PR_{PR番号}) にビルドするフローを実行します。

ポイント
PRに含まれる dbt model を単一のデータセット配下にまとめることには拘りました。これにより視認性が向上し、依頼者・レビュアーともに BigQuery コンソールから容易にデータを確認できます。

以下にその具体的な実装ステップを解説します。

2-1. 変更されたモデルの検出

まず、GitHub Actions では、tj-actions/changed-filesなどを使ってPRで変更された dbt model を検出します。検出した モデル名は、後続の job に渡すために環境変数として保存します。

jobs:
  extract_dbt_models:
    runs-on: ubuntu-latest
    outputs:
      MODEL_NAMES: ${{ steps.extract-model-names.outputs.MODEL_NAMES }}
    steps:
      # ソースコードをチェックアウト
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

      # PRで変更されたファイルを検出
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47
        with:
          files: |
            dbt/models/**/*.sql

      # ファイルパスからモデル名を抽出
      # - dbt/models/hoge/model_name.sql → model_name
      - name: Extract model names from changed files
        id: extract-model-names
        run: |
          CHANGED_FILES="${{ steps.changed-files.outputs.all_changed_files }}"
          MODEL_NAMES=""
          for file in $CHANGED_FILES; do
            model_name=$(basename "$file" .sql)
            MODEL_NAMES="$MODEL_NAMES $model_name"
          done
          MODEL_NAMES=$(echo $MODEL_NAMES | xargs) # 先頭・末尾の空白削除
          echo "Extracted model names: ${MODEL_NAMES}"
          echo "MODEL_NAMES=${MODEL_NAMES}" >> $GITHUB_OUTPUT

2-2. dbt build の実行

次に、抽出したモデル名を --select オプションに渡し、変更分のみをビルドします。 この際、--vars オプションを通じて「PR番号」と「PR対象モデル(変更したモデル)」も dbt に渡します。

      # 変更されたモデルのみをビルド
      # - SELECT で指定したモデルは 一時データセットにリダイレクトして本番環境に影響を与えない
      - name: Build changed models
        id: dbt-build
        if: ${{ env.MODEL_NAMES != '' && env.PR_NUMBER != '' }}
        run: |
          echo "Building models: ${{ env.MODEL_NAMES }}"
          uv run dbt build \
              --target prod \
              --cache-selected-only \
              --select ${{ env.MODEL_NAMES }} \
              --vars '{"pr_number": "${{ env.PR_NUMBER }}","pr_target_models": "${{ env.MODEL_NAMES }}"}'
        working-directory: dbt/my_dbt_project

ここで、vars引数と、dbt マクロのオーバーライドを組み合わせて、以下のような処理をしています。

一時データセットへのリダイレクト(generate_schema_name のオーバーライド)

generate_schema_name マクロでは、以下のような処理をしています。

  • PR対象モデル: PR専用の一時データセット(tmp_PR_123 など)に作成
  • それ以外のモデル: 本番データセットをそのまま参照
{% macro is_pr_mode() %}
  {{ return(var("pr_number", none) is not none) }}
{% endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}
    {# 変数を取得 #}
    {%- set node_name = node.name -%}
    {%- set default_schema = target.schema -%}
    {%- set pr_number = var("pr_number", "") -%}
    {%- set pr_target_models = var("pr_target_models", "").split() -%}

    {%- if is_pr_mode() and node_name in pr_target_models -%}
        {# PRモード かつ 現在のモデルがPR対象に含まれている場合はPR固有のスキーマを使用 #}
        {{ "tmp_PR_" ~ pr_number }}
    {%- elif custom_schema_name is not none -%}
        {# config で schema が設定されている場合はそれを使用 #}
        {{ custom_schema_name | trim }}
    {%- else -%}
        {# デフォルト処理: target.schema をそのまま使用 #}
        {{ default_schema }}
    {%- endif -%}
{%- endmacro %}

エイリアス名の衝突防止(generate_alias_name のオーバーライド)

dbt では、alias を使うことで、モデル名とは異なるテーブル名を指定できます。つまり、以下のようなテーブルを作成することができます。

dataset_1.hoge_table
dataset_2.hoge_table

しかし、PRデータセットでは、同じエイリアス名を持つテーブルが複数存在すると衝突してしまいます。 そこで、PRデータセットでは、エイリアス名を使用せず、必ずデフォルトのモデル名を使用します。

{% macro generate_alias_name(custom_alias_name=none, node=none) -%}
    {# 変数を取得 #}
    {%- set node_name = node.name -%}
    {%- set pr_target_models = var("pr_target_models", "").split() -%}

    {%- if is_pr_mode() and node_name in pr_target_models -%}
        {# PRモード かつ 現在のモデルがPR対象に含まれている場合はデフォルトエイリアスを使用 #}
        {{ node_name }}
    {%- elif custom_alias_name -%}
        {# config() で alias が設定されている場合はそれを使用 #}
        {{ custom_alias_name | trim }}
    {%- else -%}
        {# デフォルト処理: node.name をそのまま使用 #}
        {{ node_name }}
    {%- endif -%}
{%- endmacro %}

Incrementalモデルは常に差分更新(is_incremental のオーバーライド)

Incremental モデルを初回実行する場合、通常は全件ビルドが行われるため、コストが高額になりがちです。 また、PRデータセットの主な目的は「SQLロジックの動作検証」であり、必ずしも全期間のデータを処理する必要はありません。

そこで、PRデータセット下では is_incremental() が常に true を返すようにマクロをオーバーライドし、強制的に差分更新モードとして動作させます。

{% macro is_incremental() %}
    {# PRデータセットでは常にtrueを返し、常に差分処理を行う #}
    {% if is_pr_mode() %}
        {{ return(true) }}
    {% else %}
        {{ return(dbt.is_incremental()) }}
    {% endif %}
{% endmacro %}

このオーバーライドにより、PRデータセットでは初回実行であっても is_incremental() が true となり、モデル内で定義された期間フィルタ(例:直近7日間)が自動的に適用されます。これにより、スキャン量を必要最小限に抑え、コスト削減ができます。

select * from {{ ref("hoge_models") }}
where true
    {% if (is_incremental()) %}
        -- PRデータセットでは、初回実行でもこのフィルタが適用される
        and jst_date >= date_sub(current_date(), interval 7 day)
    {% endif %}

ポイント: defer 機能の検討
ここまで読むと、dbt に詳しい方であれば「dbt の defer 機能を使えば、よりシンプルに実現できるのでは?」と感じるかもしれません。

実際に defer の採用も検討しましたが、本構成では「PR で作成されるテーブルは単一のデータセットに集約する」という設計方針を採っています。その場合、defer を使ったとしても generate_schema_name などの macro オーバーライドが不可避でした。

defer を用いることで、変更のあったモデルの特定を dbt に委ねられるというメリットはありますが、その一方で、manifest ファイルの管理や、参照先が暗黙的に切り替わることによる挙動の分かりにくさといった運用コストも発生します。

これらを総合的に考慮した結果、本ケースでは defer の導入による効果は限定的と判断し、明示的に制御できる macro ベースのアプローチを採用しています。

ステップ3: コンパイル済みSQLと PRデータセットのリンクをコメント

ビルド成功後、dbt がコンパイルした SQL を PR に自動コメントします。

      # PR で作成された dbt の compiled された SQL を pull request のコメントに追加
      # partial parsing を使うために  vars を同じにする
      - name: Get compiled dbt model paths
        id: compiled-paths
        if: ${{ env.MODEL_NAMES != '' && env.PR_NUMBER != '' }}
        run: |
          dbt_model_paths_json=$(uv run dbt ls \
              --quiet \
              --target prod \
              --no-populate-cache \
              --resource-type model \
              --select ${{ env.MODEL_NAMES }} \
              --vars '{"pr_number": "${{ env.PR_NUMBER }}","pr_target_models": "${{ env.MODEL_NAMES }}"}' \
              --output json \
              --output-keys original_file_path \
          )
          dbt_compiled_paths=$(echo "$dbt_model_paths_json" | jq -r -s 'map("dbt/my_dbt_project/target/compiled/my_dbt_project/" + .original_file_path) | unique | join(" ")')
          echo "dbt_compiled_paths:: ${dbt_compiled_paths}"

          # 出力は1行になるため、シンプルな echo で設定できる
          echo "COMPILED_PATHS=${dbt_compiled_paths}" >> $GITHUB_OUTPUT
        working-directory: dbt/my_dbt_project

      - name: Comment compiled SQL on PR
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        if: steps.compiled-paths.outputs.COMPILED_PATHS != ''
        env:
          COMPILED_PATHS: ${{ steps.compiled-paths.outputs.COMPILED_PATHS }}
          PR_NUMBER: ${{ env.PR_NUMBER }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            ##### 長いのでコア部分のみ抜粋 #####
            const fs = require('fs');
            const path = require('path');
            const compiledPaths = process.env.COMPILED_PATHS.split(' ');

            let bodySQLs = `## Compiled SQLs for changed models:\n\n`;
            for (const fullPath of compiledPaths) {
              const fileName = path.basename(fullPath);
              let detailBlock;

              try {
                const sql = fs.readFileSync(fullPath, 'utf8');
                detailBlock = `<details><summary>✅ ${fileName}</summary>\n\n\`\`\`sql\n${sql.trim()}\n\`\`\`\n\n</details>\n`;
              } catch (error) {
                detailBlock = `<details><summary>🚫 ${fileName}</summary>\n\n\`\`\`\nError: ${error.message}\n\`\`\`\n\n</details>\n`;
                console.error(`Error reading file ${fullPath}: ${error.message}`);
              }

              bodySQLs += detailBlock;
              totalLength += detailBlock.length;
            }
            ##### 長いのでコア部分のみ抜粋 #####

最終的に、以下のようなコメントが作成されます。これにより、BigQueryでそのまま実行できるコンパイル済みSQLをワンクリックで取得できます。

GitHub コメントの例

ステップ4: AIレビュー

対応する課題: ② コーディングルールの遵守確認

CI実行と並行して、devin_coding_style_review ラベルが付いた PR に対して Devin AI が自動的にコーディングスタイルをレビューします。

レビュー観点の明文化

レビューの属人化を防ぐため、以下のドキュメントを整備し、Devin に読み込ませています。違反があれば、PR コメントで指摘されるので、それをもとに Devin が修正をします。

  • review_check_list.md: レビュー観点のチェックリスト
  • coding_style_guide.md: コーディングスタイルガイド

ステップ5: データ確認とフィードバック

対応する課題: ③ SQLロジックの確認

ビルド完了時に、PR 作成者の Slack スレッドに自動通知します。

依頼者は、Slack通知を受け取った後、BigQueryの一時データセット (tmp_PR_{PR番号}) で実際のデータを確認します。もし意図したデータと異なる場合は、Devinに追加指示を出してPRを更新できます。この修正ループにより、レビュアーに渡る前にデータの品質を高められます。

Slack ワークフローのコメント例

ステップ6: レビュー&マージ

レビュアーは、以下を確認してPRをレビューします:

  • コンパイル済みSQL: PRコメントで実際のSQLを確認
  • 実データ: BigQueryで一時データセットのデータを確認
  • Devinレビュー結果: AIレビューのコメントを確認

これらの情報が揃っているため、レビュー時間を大幅に短縮できます。承認後、PRをmainブランチにマージします。

ステップ7: STG デプロイ - Post-Merge

対応する課題: ④ 他のdbt modelへの影響調査

PR がマージされると、自動的にSTG環境で dbt build が実行されます。この際、対象モデルに + を付与することで、下流モデルも含めた依存関係の検証を行います。

もし build が失敗した場合は Slack に通知されます。これにより、本番デプロイ前に問題を確実に検出できます。

なぜ CI(Pre-Merge)でやらないのか?

本来であればマージ前の CI で全量チェックを行うのが理想ですが、以下の理由から Post-Merge での実施としています。

  1. 時間とコスト: 下流モデルまですべてビルドすると、CIの実行時間とコストが膨大になるため。
  2. 低頻度 : 完璧ではありませんが、Devinが作成するPRは依存関係も含めた修正PRをしてくれる可能性が高かった。
  3. リカバリの猶予: 翌日の Prod ビルド(本番更新)までに修正が間に合えば実害はない。

まとめ

本記事では、CADDi における SQL レビューを効率化するための dbt CI の実践知 を紹介しました。

この仕組みにより、レビュアーは以下の形でレビューを行えます。

  • コンパイル済み SQL は PR コメントを見るだけ
  • ビジネスロジックの初期検証は依頼者側で実施
  • コーディングルールは AI レビューで担保
  • 下流影響は Post-Merge で自動検出

結果として、SQL レビューの負荷を大幅に軽減できました。

生成 AI が作成する PR のレビューに悩んでいるデータエンジニアの方々の参考になれば幸いです。


今年は、CADDi Tech/Product Advent Calendar 2025 でも、マルチテナントSaaSで個別のBigQueryリソースを作成する際にハマった問題とその解決策について書きました。こちらも読んでいただけると嬉しいです。

TerraformのState肥大化を解消!Terramate で実現する マルチテナント SaaS のデータ基盤