dbtのカスタムスキーマを使いこなそう

# 記事を書いた背景
- この記事はアイスタイル [Advent Calendar 2022](https://qiita.com/advent-calendar/2022/istyle "Advent Calendar 2022" ) 11日目の記事です
- dbtでモデルを定義すると、基本的には接続情報：profiles.ymlにて指定したデフォルトのスキーマ（データセット）にモデルが出力されます。接続情報にて指定出来るスキーマはターゲット毎に一つのみです
- しかし、BigQueryの運用を行う場合、含まれているデータ内容などに応じて、複数のデータセットを使って権限コントロールが必要になることがあります
- dbtではカスタムスキーマという仕組みがあり、既に存在する解説サイトではモデルの配置されるフォルダ毎にカスタムスキーマ名を変える解説はよくされています
- 実際に運用しようとすると、ターゲットとフォルダの組み合わせでカスタムスキーマ名を決めたい場合など、フォルダ単位以外に細かい制御をしたいニーズがありますが、公式Tutorial以外にニーズに合った記事が少ないのも事実です
- この記事では、dbtでカスタムスキーマの使い方について、少し踏み込んだ解説を行います

# 想定読者
- dbtとは何かを理解している人
- データウェアハウスの構築・運用を担当している人
- データウェアハウス製品はBigQueryを使用している人

# この記事で説明しないこと
- この記事では、dbtとは何かについては説明しません
- この記事では、dbtに関する環境構築方法については説明しません

# カスタムスキーマについて
## カスタムスキーマとは
- デフォルトでは、すべてのdbtモデルはターゲットで指定されたスキーマに対してビルドされます
- 多くのモデルがあるdbtプロジェクトでは、ターゲットで指定されたスキーマ以外のスキーマに対してモデルをビルドできて便利かもしれません。例えば、論理的にモデルをグループ化するような場合が考えられます
    - マーケティング、人事、営業などビジネス領域ごとにグルーピングしたい場合
    - 加工途中の中間モデルをステージング用のスキーマに隠蔽し、データ利用ユーザに公開するモデルを分析用スキーマに配置する場合
- このように、ターゲットで指定されたスキーマ以外のことを「カスタムスキーマ」と言います

## カスタムスキーマを定義する際の視点
- カスタムスキーマを使って、どのようなスキーマ（データセット）でテーブルを管理するかを決める際の視点としては以下のものがあります
    - 生成されるスキーマの命名規約
        - 設定されたカスタムスキーマを元に生成するスキーマはデフォルトではカスタムスキーマをターゲットスキーマに連結して生成します
        - ターゲットスキーマ名を命名規約に含まない場合、複数のユーザが同時に開発した場合に同一のスキーマにアクセスされてしまいますし、本番環境と開発環境で同一名称のスキーマが出来てしまいます
        - dbtデフォルトの命名規約に従うことが出来るかを決める必要があります
    - カスタムスキーマを何単位に設定するか
        - カスタムスキーマはフォルダ単位やモデル単位で設定することが出来ます
        - 中間モデルに関してスキーマを分ける場合などは、こちらに該当します
        - 少し実装は複雑ですが、ドメイン＋レイヤ単位にスキーマを変えることも可能です
- それでは、具体的にどのようにカスタムスキーマを設定するのかを次節から見ていきましょう

# カスタムスキーマの設定パターン
- 前述の視点を組み合わせると、カスタムスキーマを設定するパターンとして以下のものがありえることが分かります
1\. モデル単位にカスタムスキーマを設定し、dbtの命名ルールに従ってスキーマ名を決定する
2\. フォルダ単位（レイヤ単位）にカスタムスキーマを設定し、dbtの命名ルールに従ってスキーマ名を決定する
3\. 独自の命名ルールに従ってスキーマ名を決定する

- それではカスタムスキーマを用いて、どのようにスキーマ名を決定しているのかを見ていきましょう
- 開発環境に[jaffle_shop](https://github.com/dbt-labs/jaffle_shop "https://github.com/dbt-labs/jaffle_shop")プロジェクト環境が構築されているものとします
- `profiles.yml`で設定されているtargetはdev/stg/prdの3環境とし、ターゲットスキーマ（ターゲット毎のスキーマ）は`{target}_ds`とします

# 1\. モデル単位にカスタムスキーマを設定し、dbtの命名ルールに従ってスキーマ名を決定する
:::note info
概略
モデルファイルの中でカスタムスキーマを設定し、ターゲットスキーマと組み合わせてスキーマ名を決定します
:::
- `/jaffle_shop/models/staging/stg_customers.sql`を開きます
- モデルの先頭に`{{ config(schema='staging') }}`を追加します
- `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
- データセット`dev_ds_staging`に`stg_customers`が作成されていることを確認します

# 2\. フォルダ単位（レイヤ単位）にカスタムスキーマを設定し、dbtの命名ルールに従ってスキーマ名を決定する
 :::note info
概略
dbt_project.ymlの中でレイヤ毎にカスタムスキーマを設定し、ターゲットスキーマと組み合わせてスキーマ名を決定します
:::
- `jaffle_shop/dbt_project.yml`を開きます
- modelsセクションを以下の内容に変更します

    ```yml
    models:
      dbt_projects:
        materialized: table
        staging:
          materialized: view
          +schema: staging
    ```
- `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
- データセット`dev_ds_staging`が作成され、`staging`のモデルが出力されていることを確認します

# 3\. 独自の命名ルールに従ってスキーマ名を決定する
- この節では、dbtのカスタムスキーマを元にスキーマ名を生成する方法について見ていきます
- dbtではgenerate_schema_nameというマクロを使って、モデルを構築するスキーマの名前を決定しています。現在のロジックを表現しているマクロのコードは以下の通りです

    ```jinja
    {% macro generate_schema_name(custom_schema_name, node) -%}
    
        {%- set default_schema = target.schema -%}
        {%- if custom_schema_name is none -%}
    
            {{ default_schema }}
    
        {%- else -%}
    
            {{ default_schema }}_{{ custom_schema_name | trim }}
    
        {%- endif -%}
    
    {%- endmacro %}
    ```

- このマクロを上書きするために、macroフォルダに独自の命名ルールに従ったスキーマ名を生成するロジックを実装した`generate_schema_name`を定義することで、独自の命名ルールに従ってスキーマ名を生成することが出来ます（`generate_schema_name`を上書きするためのファイルは`get_custom_schema.sql`という名称にすることが多いようです）

- 独自の命名ルールに従ってスキーマ名を決定する方法は、以下の4パターンの方式があります
    - 3-1\.フォルダ（レイヤ）毎に指定したカスタムスキーマ名をスキーマ名にする
    - 3-2\.フォルダ（レイヤ）＋ターゲット毎に指定したカスタムスキーマ名をスキーマ名にする
    - 3-3\.フォルダ（レイヤ）毎に指定したカスタムスキーマ名とターゲット名の組み合わせでスキーマ名を生成する
    - 3-4\. 設定ファイル内で、レイヤ＋ターゲット毎にカスタムスキーマを指定し、特定モデルだけは別のスキーマ名を生成する
- 具体的にそれぞれ見ていきましょう

## 3-1\.フォルダ（レイヤ）毎に指定したカスタムスキーマ名をスキーマ名にする
:::note info
概略
カスタムスキーマ名をフォルダ（レイヤ）単位に指定し、スキーマ名を生成するロジックの中でカスタムスキーマをそのままスキーマ名とします
:::
- 3-1. では、前述した`get_custom_schema.sql`を用いて、スキーマ名を独自の命名ルールで設定します。`get_custom_schema.sql`では、カスタムスキーマ名をそのまま返すようにします
- カスタムスキーマ名は`dbt_project.yml`でフォルダ（レイヤ）毎に設定していますが、モデル毎に設定しても挙動としては変わりません
- それでは、実際に設定してみましょう
    - a\. `dbt_project.yml`のmodelsセクションを以下のように書き換えます
        ```yml
        models:
          dbt_projects:
            materialized: table
            staging:
              materialized: view
              +schema: staging
        ```
    - b\. `get_custom_schema.sql`をmacroフォルダ配下に作成し、以下の内容を貼り付けます
        - カスタムスキーマが定義されている場合はカスタムスキーマをそのまま使用し、カスタムスキーマが未定義の場合はデフォルトスキーマを使用する、という実装をしています

        ```jinja
        {% macro generate_schema_name(custom_schema_name, node) -%}
        
            {%- set default_schema = target.schema -%}
            {%- if (target.name == 'prd' or target.name == 'dev') and custom_schema_name is not none -%}
        
                {{ custom_schema_name | trim }}
        
            {%- else -%}
        
                {{ default_schema }}
        
            {%- endif -%}
        
        {%- endmacro %}
        ```
    - c\. モデルを実行します
        - `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
        - データセット`staging`が作成され、`staging`フォルダ（レイヤ）のモデルが出力されていることを確認します


## 3-2\.フォルダ（レイヤ）＋ターゲット毎に指定したカスタムスキーマ名をスキーマ名にする
:::note info
概略
設定時にレイヤ＋ターゲット毎に別々のカスタムスキーマを設定し、スキーマ名にそのままカスタムスキーマ名を指定します
:::

- 3-1. との違いは、設定ファイルでカスタムスキーマを設定する際、フォルダ（レイヤ）だけではなく、ターゲット毎に別々のカスタムスキーマを設定します。これまで、`dbt_project.yml`にてフォルダ（レイヤ）毎にカスタムスキーマを設定してきましたが、ターゲット毎に分岐させます
- `get_custom_schema.sql`の設定は3-1. 同様、カスタムスキーマをそのまま返します
- それでは、実際に設定してみましょう
    - a\. `dbt_project.yml`のmodelセクションを以下のように書き換えます
        ```yml
        models:
          dbt_projects:
            materialized: table
            staging:
              materialized: view
              +schema: |
                {%- if  target.name == "prd" -%} staging_prd
                {%- elif target.name == "dev"  -%} staging_dev
                {%- else -%} invalid_database
                {%- endif -%}
        ```
    - b\. macroフォルダ配下に`get_custom_schema.sql`を作成し、以下の内容を貼り付けます
        ```jinja
        {% macro generate_schema_name(custom_schema_name, node) -%}
        
            {%- set default_schema = target.schema -%}
            {%- if (target.name == 'prd' or target.name == 'dev') and custom_schema_name is not none -%}
        
                {{ custom_schema_name | trim }}
        
            {%- else -%}
        
                {{ default_schema }}
        
            {%- endif -%}
        
        {%- endmacro %}
        ```
    - c\. モデルを実行します
        - `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
        - データセット`staging_dev`が作成され、stagingフォルダ（レイヤ）のモデルが出力されていることを確認します

## 3-3\.フォルダ（レイヤ）毎に指定したカスタムスキーマ名とターゲット名の組み合わせでスキーマ名を生成する
:::note info
概略
設定時にレイヤ毎に別々のカスタムスキーマを設定し、スキーマ名の生成の際にターゲット名と組み合わせて独自の命名規約でスキーマ名を生成します
:::
- 設定ファイルではフォルダ（レイヤ）毎にカスタムスキーマを指定し、スキーマ名を生成するマクロの中でターゲット名を動的に取得し、カスタムスキーマ名と結合してスキーマ名にしています
- 設定ファイルではフォルダ（レイヤ）毎にカスタムスキーマを指定するのは3-1. 同様です
- それでは、実際に設定してみましょう
    - a\. dbt_project.ymlのmodelセクションを以下のように書き換えます
        ```yml
        models:
          dbt_projects:
            materialized: table
            staging:
              materialized: view
              +schema: staging
        ```
    - b\. macroフォルダ配下にget_custom_schema.sqlを作成し、以下の内容を貼り付けます
        ```jinja
        {% macro generate_schema_name(custom_schema_name, node) -%}
        
            {%- set default_schema = target.schema -%}
            {%- if (target.name == 'prd' or target.name == 'dev') and custom_schema_name is not none -%}
        
                {%- set fqn = node['fqn'] -%}
                {%- set schema = '_'.join(fqn[1:-1]) -%}
                {{ schema }}_{{ target.name }}
        
            {%- else -%}
        
                {{ default_schema }}
        
            {%- endif -%}
        
        {%- endmacro %}
        ```
    - c\. モデルを実行します
        - `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
        - データセット`staging_dev`が作成され、stagingフォルダ（レイヤ）のモデルが出力されていることを確認します

- カスタムスキーマ名はマクロの引数に独立して存在するため、この引数をそのまま使用したのが3-1. ですが、ここではnode引数を使用して様々なスキーマ名を生成することを例示したかったので、あえてnodeからfqnを使用してカスタムスキーマ名を取得しています
- `{{ schema }}_{{ target.name }}`を
`{{custom_schema_name }}_{{ target.name }}`と書き換えても、同じ結果を得ることは出来ます
- node引数から取得できるのは以下の内容です
    | パラメータ | 概要        |
    |:-----------|:------------|
    | alias      | エイリアス  |
    | databas    | デフォルトデータベース名  |
    | fqn        | Fully Qualified Name     |
    | name       | モデル名                 |
    | original_file_path| dbtプロジェクトディレクトリ内でのパス|
    | package_name| dbtプロジェクト名|
    | path| モデルディレクトリ内のモデルファイルのパス|
    | raw_sql| SQLファイルの定義内容|
    | root_path| dbtプロジェクトディレクトリまでのパス|
    | schema| デフォルトスキーマ|
    | unique_id| プロジェクトで一意なモデルID。model.(dbtプロジェクト名).(モデル名)|

    [参考](https://schemas.getdbt.com/dbt/manifest/v0/index.html#nodes "nodes" )

## 3-4\. 設定ファイル内で、レイヤ＋ターゲット毎にカスタムスキーマを指定し、特定モデルだけは別のスキーマ名を生成する
:::note info
概略
設定時にレイヤ＋ターゲット毎に別々のカスタムスキーマを設定し、スキーマ名の生成の際に基本的にはそのままカスタムスキーマをスキーマとして出力し、特定のルールに該当する場合モデルだけルールに従ったスキーマを出力します
:::
- 設定時にレイヤ＋ターゲット毎にカスタムスキーマを設定するのは3-2と同様です。
- `get_custom_schema.sql`は特定のルールに該当するかを判断し、ルールに該当しない場合はカスタムスキーマ名をスキーマ名として出力します。今回のサンプルにおける特定のルールは`モデル名＝stg_customers`かどうかを判定し、特定ルールの場合は、`カスタムスキーマ名_customer`を出力することとします
- 今回のサンプルはモデル名で分岐をしていますが、モデル名に業務ドメインを含んでいるのであればモデル名から業務ドメイン名を取得し、レイヤ＋ターゲット＋業務ドメイン毎に異なるスキーマ（データセット）に出力することも可能です

- それでは、実際に設定してみましょう
    - a\. dbt_project.ymlのmodelセクションを以下のように書き換えます
        ```yml
        models:
          dbt_projects:
            materialized: table
            staging:
              materialized: view
              +schema: |
                {%- if  target.name == "prd" -%} staging_prd
                {%- elif target.name == "dev"  -%} staging_dev
                {%- else -%} invalid_database
                {%- endif -%}
        ```

    - b\. macroフォルダ配下に`get_custom_schema.sql`を作成し、以下の内容を貼り付けます
        ```jinja
        {% macro generate_schema_name(custom_schema_name, node) -%}
        
            {%- set default_schema = target.schema -%}
            {%- if (target.name == 'prd' or target.name == 'dev') and custom_schema_name is not none -%}
                {%- set name = node['name'] -%}
                {%- if (name == 'stg_customers') -%}
                    {%- set schema = 'customer' -%}
        
                    {{ custom_schema_name | trim }}_{{ schema }}
        
                {%- else -%}
        
                    {{ custom_schema_name | trim }}
        
                {%- endif -%}
        
            {%- else -%}
        
                {{ default_schema }}
        
            {%- endif -%}
        
        {%- endmacro %}
        ```
    - c\. モデルを実行します
        - `dbt seed`でテストデータを挿入した後、`dbt run（target=dev）`を実行します
        - データセット`staging_dev`が作成され、stg_customers以外のstagingフォルダ（レイヤ）のモデルが出力されていることを確認します
        - データセット`staging_dev_customer`が作成され、`stg_customers`が出力されることを確認します

# 最後に
- ある程度の規模の基盤であれば、ターゲット＋ドメイン＋レイヤ毎にアクセス権限管理が必要になることもあると思います。そのような場合に、当記事は参考になるかもしれません
- ただし、当記事の方式はかなり複雑になるので、データセットはターゲット毎に一つにして、テーブルごとにアクセス権限を設定しても良いかもしれません

# 参考
- [dbt + BigQueryで出力するデータセットを変更する](https://qiita.com/munaita_/items/29a23815240e37c8ee31 "dbt + BigQueryで出力するデータセットを変更する")
- [[dbt] custom schemaを使って普段とは別のスキーマ下にデータモデルを作成する](https://dev.classmethod.jp/articles/dbt-custom-schema/ "[dbt] custom schemaを使って普段とは別のスキーマ下にデータモデルを作成する")
- [Custom schemas](https://docs.getdbt.com/docs/build/custom-schemas "Custom schemas")