dbtを使っている場合のバージョン間の差分をいい感じに出すスクリプトを作った

前回に引き続き、dbtの運用メモです。簡単にまとめると、こういう感じです。

  • dbtを使ってDWHを構築しているが、バージョン管理している
  • dbtの都合上、バージョン間の差分が確認しにくい
  • いい感じに差分を確認できるスクリプトを作った

背景

背景: dbtの制約でnameを一意にしないといけない

dbtのpropertiesはymlファイルに書けて、モデルのnameはここに書かれる。例えばこういう感じ。

version: 2

models:
  - name: my_first_dbt_model
    description: "A starter dbt model"
    columns:
      - name: id
        description: "The primary key for this table"
        tests:
          - unique
          - not_null

  - name: my_second_dbt_model
    description: "A starter dbt model"
    columns:
      - name: id
        description: "The primary key for this table"
        tests:
          - unique
          - not_null

ここに書かれるnameはファイル名と紐付けられ、かつ一意にする必要がある。

Do model names need to be unique? Yes! To build dependencies between models, you need to use the ref function. The ref function only takes one argument — the model name (i.e. the filename). As a result, these model names need to be unique, even if they are in distinct folders.

グローバルに一意になるように人間が気を付けるのは本当に面倒なので(マジでどうにかなって欲しい...)、ある程度機械的にnameを付けたファイルを生成してくれるdbt-helperを利用しています。これを利用してファイルを作っていくと、こういう形になります。

  • models/my_datawarehouse_dev/user/intermediate/my_datawarehouse_dev__user__intermediate__user_some_attribute.sql

このファイル名のまま素朴にdbt runすると、めちゃくちゃ長い名前のテーブルが生成されて使いにくいです。実際はカスタムエイリアスを併用することになるでしょう。

背景: DWHのバージョン管理をしている

しています。背景は昔に書いたので、そちらを読んでください。

課題: バージョン間の差分を知るのが難しい

これまで述べたように、dbtを使いつつ、DWHのバージョン管理をしようとすると、ディレクトリ構成がこういう感じになってきます(intermediate配下には他にもdbtで分割した10個くらいSQLファイルがあったりする)。

  • models/my_datawarehouse_dev/user_versionized/user_v0_0_1/intermediate/my_datawarehouse_dev__user_versionized__user_v0_0_1__intermediate__user_some_attribute.sql
  • models/my_datawarehouse_dev/user_versionized/user_v0_0_2/intermediate/my_datawarehouse_dev__user_versionized__user_v0_0_2__intermediate__user_some_attribute.sql
  • models/my_datawarehouse_dev/user_versionized/user_v0_0_3/intermediate/my_datawarehouse_dev__user_versionized__user_v0_0_3__intermediate__user_some_attribute.sql

(心の声: ファイル名が長すぎて横にブチ抜けてしまった...!)

データの検証をするときやユーザーからの問い合わせで「v0.0.1とv.0.0.2の違いをSQLレベルでさっと知りたい」という場合があります。ディレクトリ間の差分についてはdiff -r dir_a dir_bのような形で出すこともできますが、ファイル名が同じ場合のみです。今回の場合はディレクトリ名やファイル名も異なるので、素朴にはバージョン間の差分を出すことができません。困った。

解決法: 差分を出すスクリプトを自前で書く

何とかできないものかなと思ったけど、まあ自分でスクリプトを書きました。実際の中身は20行くらいなので、まあメンテナンスもそんなに大変ではないでしょう。中身でやっていることは

  • compileしたSQLファイルで比較
    • jinjaのテンプレートの変数などが展開された後の比較をしたいので
  • バージョン間だけでなく、(GCPの)プロジェクト名が異なる場合にも対応できるように
    • 開発環境はmy_datawarehouse_devプロジェクト、本番環境はmy_datawarehouseプロジェクトでやっていたりするので
  • 実際の差分を取るのはdiff -rにお任せ
  • ファイルの適当のお掃除

という感じ。

#!/bin/bash
set -eu -o pipefail

# dbtのモデルファイルをバージョン跨ぎで比較するためのスクリプト
# 基本的には`diff -r`で比較するが、そのためにファイル名やプロジェクト名をrenameする
# sqlファイルを変更した場合にはcompileしなおす必要があるので注意(`dbt compile --target local --project-dir . --profiles-dir`)

usage() {
  cat <<EOF
usage:
  $0 --from_project FROM_PROJECT --from_dataset FROM_DATASET --from_table FROM_TABLE --to_project TO_PROJECT --to_dataset TO_DATASET --to_table TO_TABLE

options:
  --from_project ベースとなるプロジェクト名
  --from_dataset ベースとなるデータセット名 
  --from_table   ベースとなるテーブル名
  --to_project   比較対象のプロジェクト名 
  --to_dataset   比較対象のデータセット名
  --to_table     比較対象のテーブル名 
EOF
  exit 1
}

# diffの比較対象A
FROM_PROJECT=""
FROM_DATASET=""
FROM_TABLE=""

# diffの比較対象B
TO_PROJECT=""
TO_DATASET=""
TO_TABLE=""

DBT_PROJECT_NAME="my_new_project"

flag_parse() {
  for i in $*; do
    case $i in
      "--from_project")
        FROM_PROJECT="1"
        ;;
      "--from_dataset")
        FROM_DATASET="1"
        ;;
      "--from_table")
        FROM_TABLE="1"
        ;;

      "--to_project")
        TO_PROJECT="1"
        ;;
      "--to_dataset")
        TO_DATASET="1"
        ;;
      "--to_table")
        TO_TABLE="1"
        ;;
      
      "--dbt_project_name")
        DBT_PROJECT_NAME="1"
        ;;

      "--help")
        usage
        ;;
      *)
        [[ "${FROM_PROJECT}" == "1" ]] && FROM_PROJECT=$i && continue
        [[ "${FROM_DATASET}" == "1" ]] && FROM_DATASET=$i && continue
        [[ "${FROM_TABLE}" == "1" ]] && FROM_TABLE=$i && continue
        
        [[ "${TO_PROJECT}" == "1" ]] && TO_PROJECT=$i && continue
        [[ "${TO_DATASET}" == "1" ]] && TO_DATASET=$i && continue
        [[ "${TO_TABLE}" == "1" ]] && TO_TABLE=$i && continue
        
        [[ "${DBT_PROJECT_NAME}" == "1" ]] && DBT_PROJECT_NAME=$i && continue
       
        usage
        ;;
    esac
    shift
  done
}

# main
[[ $# == "0" ]] && usage
flag_parse $*

tmpfile=$(mktemp -d dbt-diff-XXX)

function rm_tmpfile {
  [[ -d "${tmpfile}" ]] && rm -rf "${tmpfile}"
}
trap rm_tmpfile EXIT # 正常終了したとき
trap 'trap - EXIT; rm_tmpfile; exit -1' INT PIPE TERM # 異常終了したとき

cp -r "../src/target/compiled/${DBT_PROJECT_NAME}/models/${FROM_PROJECT}/${FROM_DATASET}/${FROM_TABLE}" ${tmpfile}/a
# uniquenessのテストなどに関するSQLも生成されるが、それは無視したいので削除
rm -rf ${tmpfile}/a/schema*

cp -r "../src/target/compiled/${DBT_PROJECT_NAME}/models/${TO_PROJECT}/${TO_DATASET}/${TO_TABLE}" ${tmpfile}/b
rm -rf ${tmpfile}/b/schema*

# brew install rename
rename -s ${TO_PROJECT} ${FROM_PROJECT} ${tmpfile}/b/{**,.}/*.sql

for FILE in $(find "${tmpfile}/b" -maxdepth 3 -type f); do
  # brew install gnu-sed
  gsed -i s/${TO_PROJECT//_/-}/${FROM_PROJECT//_/-}/g ${FILE}
done

diff -rw ${tmpfile}/a ${tmpfile}/b

dbtは便利なんだけど、運用していると案外困り事が多いので、こういう知見をちまちまと書いていきたい。