dbtベストプラクティスやModel contractsについて

## 目次
本記事は、dbtについて調べたことを自分なりに整理することを目的としました。
長めの記事になりましたので、一応、目次を作りました!

[dbtベストプラクティスについて](#dbtベストプラクティスについて)
[Model contractsについて](#model-contractsについて)

## 環境
コードの動作環境は下記の通りです。
```sh
$ dbt -V
Core:
  - installed: 1.9.2
  - latest:    1.9.2 - Up to date!
Plugins:
  - postgres: 1.9.0 - Up to date!

$ psql -V
psql (PostgreSQL) 14.15 (Ubuntu 14.15-0ubuntu0.22.04.1)
```



## dbtベストプラクティスについて
dbtを使ったデータモデリングの記事で利用したプロジェクトをベースに、フォルダ構成等をdbtのベストプラクティスに寄せていきたいと思います。

https://qiita.com/s_rokuemon/items/3e66cde2a1435825f9be

### ・フォルダ構成
[公式のベストプラクティス](https://docs.getdbt.com/best-practices/how-we-structure/1-guide-overview)に基づいて再構成したのがこちらです。
``` tree
.
├── dbt_packages
├── dbt_project.yml
├── logs
├── macros
├── models
│   ├── intermediates
│   │   └── marketing
│   │       ├── _int_marketing__models.yml
│   │       ├── int_dim_customers.sql
│   │       ├── int_dim_dates.sql
│   │       ├── int_dim_products.sql
│   │       └── int_fact_sales.sql
│   ├── marts
│   │   └── marketing
│   │       ├── _marketing__models.yml
│   │       └── obt_sales.sql
│   └── staging
│       └── adventureworks
│           ├── _adventureworks__models.yml
│           ├── _adventureworks__sources.yml
│           ├── stg_adventureworks__stg_addresses.sql
│           ├── stg_adventureworks__stg_customers.sql
│           ├── stg_adventureworks__stg_dates.sql
│           ├── stg_adventureworks__stg_order_details.sql
│           ├── stg_adventureworks__stg_order_headers.sql
│           └── stg_adventureworks__stg_products.sql
├── seeds
│   └── adventureworks
│       ├── _adventureworks__seed.yml
│       ├── raw_addresses.csv
│       ├── raw_customers.csv
│       ├── raw_dates.csv
│       ├── raw_order_details.csv
│       ├── raw_order_headers.csv
│       └── raw_products.csv
├── target
├── tests
│   └── generic
│       └── validate_line_total_test.sql
```
### ・seed層
```
├── seeds
│   └── adventureworks
│       ├── _adventureworks__seed.yml
│       ├── raw_addresses.csv
│       ├── raw_customers.csv
│       ├── raw_dates.csv
│       ├── raw_order_details.csv
│       ├── raw_order_headers.csv
│       └── raw_products.csv

```
`seed`で今回扱うすべてのデータをロードしています。
ただし本来は下記の用途で利用することが推奨されています。
> モデリングには役立つが、どのソース システムにも存在しないルックアップテーブルを読み込むことです。

ソースデータ単位でサブフォルダを切っています。
これは`staging`レイヤのベストプラクティスを参考にしていますが、それは後述します。

https://docs.getdbt.com/best-practices/how-we-structure/5-the-rest-of-the-project

### ・staging
```
│   └── staging
│       └── adventureworks
│           ├── _adventureworks__models.yml
│           ├── _adventureworks__sources.yml
│           ├── stg_adventureworks__stg_addresses.sql
│           ├── stg_adventureworks__stg_customers.sql
│           ├── stg_adventureworks__stg_dates.sql
│           ├── stg_adventureworks__stg_order_details.sql
│           ├── stg_adventureworks__stg_order_headers.sql
│           └── stg_adventureworks__stg_products.sql
```
#### サブディレクトリ
`staging`レイヤでは、データソースに基づいてサブディレクトリを構成することが推奨されています。
今回は`adventureworks`というシステムから抽出されたデータと仮定して構成しています。

この構造により、`--select`構文で簡単にデータソース単位でのモデルを実行できます。
```sh
dbt build --select path:models/staging/adventureworks/*
```

#### 命名規則
> stg_[source]__[table名]s.sql

ファイル名の規則が推奨されています。
`[source]`毎にサブディレクトリに分割します。
このことにより、データソースが増えてきても、モデルがどのソースから構成されているのか、一目でわかるようになります。

#### yaml
> _[directory]__models.yml

yamlファイルはモデルフォルダ内にディレクトリごとに作成することが推奨されています。
またステージングフォルダ内では、`_[directory]__sources.yml`もそれぞれのディレクトリ内に含めます。

先頭にアンダースコアを付けることで、YAMLファイルが各フォルダーの先頭に並べ替えられ、モデルファイルと簡単に見分けがつきます。


#### そのほかの推奨事項
* ビューとしてマテリアライズする。

* `staging`モデルはソースマクロを唯一の場所であり、ステージングモデルはソーステーブルと1対1の関係を持つ必要があります。
    * 今回は`seed`で取り込んだデータを`source`として定義した後に、`staging`レイヤのモデルを実行しています。このような構成とすることで、`source`の段階で生データに対するデータ品質のチェックをできるメリットがあります。

#### ドキュメント
https://docs.getdbt.com/best-practices/how-we-structure/2-staging

https://docs.getdbt.com/best-practices/how-we-structure/5-the-rest-of-the-project


### ・intermediate層
```sh
│   ├── intermediates
│   │   └── marketing
│   │       ├── _int_marketing__models.yml
│   │       ├── int_dim_customers_joined_customers_and_addresses.sql
│   │       ├── int_dim_dates.sql
│   │       ├── int_dim_products.sql
│   │       └── int_fact_sales_joined_order_header_and_details.sql
```
#### サブディレクトリ
ステージングレイヤと同様にサブディレクトリで分けるが、ビジネス関連の領域ごとに分割することが推奨されています。
ここではマーケティング用途のデータをモデリングをしています。

ただし、はじめから複雑なディレクトリ構造を設計すればよいのではなく、常に複雑さをシンプルにすること、この目的に沿って構造化する必要があると言及されています。下記、ドキュメントより引用。

:::note
マート モデルが 10 個未満で、開発や使用に問題がない場合は、プロジェクトが成長して本当に必要になるまで、サブディレクトリを完全に省略してもかまいません (ステージングレイヤーは除きます。ステージングレイヤーでは、新しいソースシステムをプロジェクトに追加するたびにサブディレクトリを実装する必要があります)
:::

#### 命名規則 
> int_[entity]s_[verb]s.sql

厳密な名づけルールは困難だが、intermediateレイヤの動詞で考えるのが良い指針とされています。pivoted、joinedなどの変換処理を動詞化し命名します。
ドキュメントでは`int_payments_pivoted_to_orders.sql`と例示されています。

今回の例では、複数のテーブルを結合してモデル化しているため、ファイル名を`joined_A_and_B`としています。

このような命名規則によって、そのモデルがどのように構築されているのか、誰がみてもわかりやすくします。

##### a. 二重アンダースコアの削除
ビジネスに準拠した概念に移行することで、システムとエンティティを分離する必要がなくなるため、また、レイヤー内のエンティティと動詞の間には本質的な繋がりがあるため、二重アンダースコアの不要性が説明されています。

もしソースシステムレベルでの処理が必要な場合（後に結合するケースなど）は、二重アンダースコアを保持します。

#### そのほかの推奨事項
* materialized設定はephemerallyが推奨
* エンドユーザへは公開しない
* 構造の簡素化
    * 中間モデルの目的はマートモデルの複雑さを軽減すること。
        * マート層で多数のテーブルの結合を実装するのではなく、同様の目的を持つ複数のモデルを中間層で結合し、マート層ではより少ない数のテーブルからモデルを作成する。

#### ドキュメント
https://docs.getdbt.com/best-practices/how-we-structure/3-intermediate

### ・mart層
```
│   ├── marts
│   │   └── marketing
│   │       ├── _marketing__models.yml
│   │       └── obt_sales.sql
```
#### サブディレクトリ
マートが 10 個未満の場合、サブフォルダーはあまり必要ないようです。グループ化が必要な場合は、部門別にグルーピングする構造が推奨されています。

#### 命名規則
明確なルールはなく、マートの粒度を形成する概念に基づいて、平易な英語を使用して命名するように言及されています。

#### そのほかの推奨事項
* materialized設定はtableまたはincremental
* materializedに関する一般的な設定方針
    * 常に`view`から開始し（ストレージを占有せず、常に最新結果が得られるようにする）
    * `view`でクエリの実行が遅くなりすぎるときは、`table`を選択
    * `table`の構築が遅くなるときは、`incremental`を設定する

#### ドキュメント
https://docs.getdbt.com/best-practices/how-we-structure/4-marts


## Model contractsについて
ここからは、Model contractsについてです。
Model contractsとは、対象のモデルのデータ型や制約が定義通りであるか保証できる機能になります。

モデルの構築中に、列名、データ型、制約などのモデルの形状（shape）を明示的に定義することで、変換プロセスやそのモデルをクエリする際のリスク軽減につながります。
一連の保証を「契約」と定義されています。

### ・contractsのサポート
> * SQL モデル
> * モデルは次のいずれかとして実現されます
>    * `table`
>    * `view` - ビューでは列名とデータ型のサポートは限定されていますが、constraintsはサポートされません
>    * `incremental` - `on_schema_change:{append_new_columns,fail}`オプションのときサポートの対象

反対にサポートされないのは
> * Python モデル
> * materialized設定が`view`または`ephemeral`
> * その他のリソースタイプ (`sources`、`seeds`、`snapshots`など）

### ・contractを定義する
```model/marts/schema.yml
models:
  - name: <model_name>
    # required
    config:
      contract: {enforced: true}
    
    # model-level constraints
    constraints:
      - type: primary_key
        columns: [first_column, second_column, ...]
      - type: foreign_key # multi_column
        columns: [first_column, second_column, ...]
        to: ref('other_model_name')
        to_columns: [other_model_first_column, other_model_second_columns, ...]
      - type: check
        columns: [first_column, second_column, ...]
        expression: "first_column != second_column"
        name: human_friendly_name
      - type: ...
    
    columns:
      - name: first_column
        data_type: string
        
        # column-level constraints
        constraints:
          - type: not_null
          - type: unique
          - type: foreign_key
            to: ref('other_model_name')
            to_columns: [other_model_column]
          - type: ...
```
#### constraints（制約）の定義
> * type(必須):`not_null`, `unique`, `primary_key`, `foreign_key`, `check`, `custom expression`のいずれか
> * name(オプション): この制約のわかりやすい名前。一部のデータ プラットフォームでサポートされています
> columns(model-level Only): 制約を適用する列名のリスト

#### PostgreSQLで制約を定義する
PostgreSQLでの制約は`version13以降`がサポート対象のため注意が必要です。
その他のプラットフォームに依存した制限もあります。

https://www.postgresql.org/docs/current/ddl-constraints.html


### ・seedでデータ型を定義する
model contracts機能では`seeds`はサポート対象外ですが、`seeds.yml`の`config`オプションを活用することで、データ型の定義が可能です。

```
seeds:
  - name: raw_order_headers
    config: 
      column_types: 
        order_id: integer
        customer_id: integer
        order_date: date
        total_amount: float
...
```

ただし、検証した限りでは、数値型（INT）のデータが入ってくるカラムに対して文字列型(VARCHAR)を指定してもチェックはスルーされます。(INTの入力に対しBOOLEANを指定するような明らかな差異はチェックしてくれます)

余談ですが、[データコントラクト](https://datacontract.com/)の文脈では、より上流からデータの品質を担保する重要性が説かれており、後続処理へ及ぼす影響を最低限にするためにも、`seeds`や`source`層でもデータ品質をチェック機構は必要かもしれません。


## 最後に
dbtの公式ドキュメントに沿いながら過去のdbtプロジェクトを構造化してみました。
新たに知れたベストプラクティスは多く、学びが多かったです。

一部をつまみ食いして最適化を図るのではなく、ベストプラクティスに従い目的をもって全体の最適化を目指すことが、堅牢で柔軟性のある構造につながると感じました。

dbtベストプラクティスに照らしながら、プロダクトやデータ基盤、開発チーム、利用メンバーの成熟度に合わせてプロジェクトを更新必要性を感じました。

読んでくださった方に、何か一つでも得られるモノがある記事となりましたらうれしいです。