Data Contract CLI を使って BigQuery のデータの品質を検査してみた

BigQuery Advent Calendar 2024 5日目の記事です。

https://qiita.com/advent-calendar/2024/bigquery

この記事では Data Contract CLI を使った BigQuery のデータ品質の検査方法を紹介します。

## Data Contract CLI とは

https://github.com/datacontract/datacontract-cli

[公式ドキュメント](https://datacontract.com/)に以下の記載のとおり、データコントラクトの lint, test, import, export ができるコマンドラインツールとPythonライブラリです。

> The Data Contract CLI is a command line tool and Python library to lint, test, import and export data contracts.

端的に言えば次のことができます。

- yaml に基づいたデータのバリデーション
- yaml から BigQuery のスキーマなど他の形式へのインポート/エクスポート

ちなみに今回は BigQuery で扱いますがいろんな DB で使用することが可能です。

### Data Contract とはなにか

Data Contract をよく知らない方のために簡単に説明します。

Data Contract は直訳するとデータ契約ですが、個人的にはデータ提供ルールみたいな感じだと理解しています。
言うなればデータの API のようなもので、データの定義を詳細にまとめたものになります。

Data Contract は最近日本のデータエンジニアリング界隈でもよく聞くようになりました。
背景として、ここ最近日本のデータエンジニアリング界隈ではデータ基盤チームの作るデータ基盤の整備が進み、問題の発生元が自分たちのデータ基盤からデータソースにシフトしてきているのではないかと思います。
例えばデータソースから予期せぬスキーマの変更が発生した場合、データ加工パイプラインが止まってしまう可能性があります。
しかもこれはデータ基盤側ではコントロールできないため、データソース側で提供ルールを設けてもらい、変更があるときは事前に通知していただくなどの対処が必要になります。

その提供ルールが Data Contract です。

詳細については書籍の [Driving Data Quality with Data Contracts](www.amazon.co.jp/dp/B0C37FPH3D) や O'Reilly Learning Platform のアーリーアクセスの [Data Contract](https://learning.oreilly.com/library/view/data-contracts/9781098157623/)
をご参照ください。

先日も datatech-jp で [Data Contract のイベント](https://datatech-jp.connpass.com/event/331984/) があり参加しましたが、登壇された方の発表もその後の交流もとても有意義な時間を過ごせました。
そちらの資料もよろしければご参照ください。


### Data Contract CLI でできること

すでに先見の明高く Data Contract CLI をまとめている記事があるためそちらを紹介します。

https://soonraah.github.io/posts/data-contract-cli/

この記事ではなるべく上記の記事に無いことを補足していこうと思います。

## 下準備

poetry で次のような環境を用意しました。

```toml
[tool.poetry]
name = "data-contract"
version = "0.1.0"
description = "Qiitaの記事の執筆"
authors = []
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"
datacontract-cli = { extras = ["bigquery"], version = "^0.10.13" }
google-cloud-bigquery-storage = "^2.27.0"
soda-core-bigquery = "3.3.22"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
```

DDL から datacontract.yaml を生成できるため、次の SQL を使いました。
```sql
CREATE TABLE ogata-health.sleep.sleep_origin (
  value STRING OPTIONS(description = "睡眠の種類。レム睡眠、コア睡眠など。"),
  SOURCE STRING OPTIONS(description = "記録を取得したデバイス"),
  startDate STRING OPTIONS(description = "睡眠の開始時刻"),
  qty FLOAT64 OPTIONS(description = "睡眠時間"),
  endDate STRING OPTIONS(description = "睡眠の終了時刻")
) OPTIONS(description = "睡眠データのオリジナルデータのテーブル");
```

上記の SQL に対して以下を実行。

`datacontract import --format sql --source create_sleep_origin.sql > datacontract.yml`

`datacontract lint datacontract.yml` で lint の警告を確認しつつ yaml を修正して以下のような状態にしました。

```yaml
dataContractSpecification: 1.1.0
id: my-data-contract-id
info:
  title: My Data Contract
  version: 0.0.1
  description: "データコントラクトのサンプル"
servers:
  production:
    type: bigquery
    project: project_name
    dataset: sleep
models:
  sleep_origin:
    type: table
    description: "睡眠データのオリジナルデータのテーブル"
    fields:
      value:
        type: string
        description: "睡眠の種類。レム睡眠、コア睡眠など。"
      SOURCE:
        type: string
        description: "記録を取得したデバイス"
      startDate:
        type: string
        description: "睡眠の開始時刻"
      qty:
        type: float
        description: "睡眠時間"
      endDate:
        type: string
        description: "睡眠の終了時刻"
```

test するには BigQuery にアクセスするためのクレデンシャルが必要です。
[Data Contract のリポジトリ](https://github.com/datacontract/datacontract-cli?tab=readme-ov-file#bigquery)に以下の記載があります。

```text
We support authentication to BigQuery using Service Account Key. The used Service Account should include the roles:

BigQuery Job User
BigQuery Data Viewer
```
上記の権限を持ったサービスアカウントを作成してクレデンシャルキーを発行して `DATACONTRACT_BIGQUERY_ACCOUNT_INFO_JSON_PATH` でクレデンシャルキーのパスを指定してください。

ここまでやったら test が実行できる準備完了。

```
datacontract test datacontract.yml
```

こんな感じで実行すると次のような結果になりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415560/14e8c3e7-14ab-a837-7913-a7a21503e84e.png)

なにやら WARNING が出ているものの、とりあえずパスできたようです。

ちなみに BigQuery のジョブ履歴を見たら次のようなクエリが発行されていました。

```sql
SELECT column_name, data_type, is_nullable FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'sleep_origin';
```

このクエリの実行結果は次の通り。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415560/214cb262-30aa-6068-5525-7ef26fffcac8.png)

今回の test は INFORMATION_SCHEMA から取ってくるだけで完結する内容だったのでこのようなクエリで取ってきているようですね。

datacontract.yaml に設定できる項目は多岐にわたるため、設定を増やすともっといろんなクエリを発行することになるのだと思います。

例えば not null 制約の項目を追記して test を実行します。

```yaml
      value:
        type: string
        description: "睡眠の種類。レム睡眠、コア睡眠など。"
        required: true # NOT NULL 制約を contract に追加。
```
この状態でテストすると、BigQuery には次のクエリも発行されます。

```sql
SELECT 
  COUNT(CASE WHEN value IS NULL THEN 1 END) 
FROM sleep_origin
```

このように `required: true` で設定されているカラムに対して全ての値が NULL かどうかを判定するためのクエリを発行しています。

こうなると気になるのは BigQuery のスキャン料が嵩んでくるのではないかということなんですが、このあたりはまだ詳しく調査できてません。
test のたびにテーブルをフルスキャンすると運用上きついと思うんですが、また調べたら追記したいです。


基本的な内容は紹介したのでもう少し詳細を見ていきます。

## test 機能

余裕があったら追記します。
いろんな観点でテスト可能です。
文字列の長さや最大値・最小値などはもちろん、SQL のクエリ結果がしきい値を超えているかなどの判定も可能です。
engine 指定で great-expectation などのテストも実施することができます。
かなり柔軟にテストできるため、求めているものはだいたい揃うのではないでしょうか。


## export 機能

export は様々なフォーマットで export 可能です。
一覧はこんな感じです。
```text
jsonschema
pydantic-model
sodacl
dbt
dbt-sources
db
t-staging-sql
odcs
odcs_v2
odcs_v3
rdf
avro
protobuf
great-expectations
terraform
avro-idl
sql
sql-query
html
go
bigquery
dbml
spark
sqlalchemy
data-caterer
dcs
```

試しに sql で export してみました。
```
datacontract export --format sql datacontract.yml > sample.sql
```

結果
```sql
-- Data Contract: my-data-contract-id
-- SQL Dialect: snowflake
CREATE TABLE sleep_origin (
  value STRING not null,
  SOURCE STRING,
  startDate STRING,
  qty FLOAT,
  endDate STRING
);
```

description は書いてないです。
とはいえ素の DDL はあんまり生成して使うことはなさそうなので気にしなくてもいいかもしれません。

BigQuery だとこんな感じです。

```
datacontract export --format bigquery  --server production datacontract.yml > bigquery_sample.json
```


```
{
  "kind": "bigquery#table",
  "tableReference": {
    "datasetId": "sleep",
    "projectId": "ogata-health",
    "tableId": "sleep_origin"
  },
  "description": "\u7761\u7720\u30c7\u30fc\u30bf\u306e\u30aa\u30ea\u30b8\u30ca\u30eb\u30c7\u30fc\u30bf\u306e\u30c6\u30fc\u30d6\u30eb",
  "schema": {
    "fields": [
      {
        "name": "value",
        "type": "STRING",
        "mode": "REQUIRED",
        "description": "\u7761\u7720\u306e\u7a2e\u985e\u3002\u30ec\u30e0\u7761\u7720\u3001\u30b3\u30a2\u7761\u7720\u306a\u3069\u3002",
        "maxLength": null
      },
      {
        "name": "SOURCE",
        "type": "STRING",
        "mode": "NULLABLE",
        "description": "\u8a18\u9332\u3092\u53d6\u5f97\u3057\u305f\u30c7\u30d0\u30a4\u30b9",
        "maxLength": null
      },
      {
        "name": "startDate",
        "type": "STRING",
        "mode": "NULLABLE",
        "description": "\u7761\u7720\u306e\u958b\u59cb\u6642\u523b",
        "maxLength": null
      },
      {
        "name": "qty",
        "type": "FLOAT64",
        "mode": "NULLABLE",
        "description": "\u7761\u7720\u6642\u9593"
      },
      {
        "name": "endDate",
        "type": "STRING",
        "mode": "NULLABLE",
        "description": "\u7761\u7720\u306e\u7d42\u4e86\u6642\u523b",
        "maxLength": null
      }
    ]
  }
}
```

description が文字化けしました。

dbt だとこんな感じ。

```
version: 2
models:
- name: sleep_origin
  config:
    meta:
      data_contract: my-data-contract-id
    materialized: table
    contract:
      enforced: true
  description: 睡眠データのオリジナルデータのテーブル
  columns:
  - data_type: STRING
    description: 睡眠の種類。レム睡眠、コア睡眠など。
    constraints:
    - type: not_null
    name: value
  - data_type: STRING
    description: 記録を取得したデバイス
    name: SOURCE
  - data_type: STRING
    description: 睡眠の開始時刻
    name: startDate
  - data_type: FLOAT
    description: 睡眠時間
    name: qty
  - data_type: STRING
    description: 睡眠の終了時刻
    name: endDate
```

とくに注目している export を紹介します。

### datacontract.yaml を HTML で export できる

これはかなりいい機能だと思っているのですが、 `datacontract.yaml` が美しい HTML で出力できます。
次のようなコマンドで HTML を生成できます。
`datacontract export --format html datacontract.yml > sample.html`

![スクリーンショット 2024-12-05 4.33.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415560/f84eab05-9670-790e-47ce-184f0fa84b6a.png)


Data Contract でデータ仕様を確認する際に yaml では読みにくいため、運用上忌避されてしまう懸念があります。
HTML で出力すれば yaml に慣れていない人でも（慣れている人でも）Contract の内容を確認しやすくなり、運用しやすくなります。

## エラー集

エラーや思いも寄らない仕様に遭遇したので共有します。
Data Contract CLI は 2024/12/5 時点でバージョン 0.10.15 が最新です。
メジャーバージョンが 0 なだけあってけっこうバグがでたり納得のいかない仕様になってたりします。
とはいえ有望なツールであることは間違いないと思うので、今後に期待しています。
というか OSS なのでいっそ contributor になれという話かもしれません。

### DDL に description が書いていてもその DDL から datacontract.yaml を import したときに description が反映されない。

サンプルとして健康データのテーブルの SQL を持ってたので使いました。

```sql
CREATE TABLE project_name.sleep.sleep_origin (
  value STRING OPTIONS(description = "睡眠の種類。レム睡眠、コア睡眠など。"),
  SOURCE STRING OPTIONS(description = "記録を取得したデバイス"),
  startDate STRING OPTIONS(description = "睡眠の開始時刻"),
  qty FLOAT64 OPTIONS(description = "睡眠時間"),
  endDate STRING OPTIONS(description = "睡眠の終了時刻")
) OPTIONS(description = "睡眠データのオリジナルデータのテーブル");
```

`datacontract import --format sql --source create_sleep_origin.sql > datacontract_sample.yml` で `datacontract.yaml` を生成すると次の結果になりました。

```yaml
dataContractSpecification: 1.1.0
id: my-data-contract-id
info:
  title: My Data Contract
  version: 0.0.1
models:
  sleep_origin:
    type: table
    fields:
      value:
        type: string
        required: true
      SOURCE:
        type: string
      startDate:
        type: string
        required: true
      qty:
        type: float
        required: true
      endDate:
        type: string
        required: true
```

required はうまく追加されていますが description は追加されないようですね。悲しい気持ちになりました。
これから既存のテーブルを元に datacontract.yaml を作ろうとしている方には悲報だと思います。
手動で description を追記しました。


### `datacontract test datacontract.yaml` を実行した際に次のエラーが出る
```
datacontract test datacontract.yml
Testing datacontract.yml
ERROR:soda.scan:[02:42:07] Error occurred while executing scan.
ERROR:soda.scan:  | Data source type "bigquery" not found. Did you spell bigquery correctly? Did you install module soda-core-bigquery?
```
datacontract-cli を追加した後に `soda-core-bigquery` を `poetry add soda-core-bigquery` で追加しようとしたらエラーが出ました。
datacontract-cli と soda-core-bigquery のバージョンを最新のものではなく適当に下げたらインストールできました。検証に使用したバージョンは次のとおりです。
```toml
[tool.poetry.dependencies]
python = "^3.12"
datacontract-cli = { extras = ["bigquery"], version = "^0.10.13" }
google-cloud-bigquery-storage = "^2.27.0"
soda-core-bigquery = "3.3.22"
```

### `datacontract-cli[all]` で install するとエラーが出て test コマンドが実行できない。
datacontract リポジトリの REAMDE に書いてあったので無邪気にやったら次のエラーがでて実行できませんでした。

```text
ERROR:soda.scan:[03:39:15] Error occurred while executing scan.
ERROR:soda.scan:  | numpy.dtype size changed, may indicate binary incompatibility. Expected 96 from C header, got 88 from PyObject
WARNING:root:Engine soda-core has errors. See the logs for details.
```

素直に `datacontract-cli[bigquery]` のみにしたら解消できました。

## 終わりに

バグがでたりよくわからない仕様になってたりしますが、データの品質確認を手軽に実施できる将来有望なツールです。

僕の関心は他のツールとの競合です。
すでにデータ基盤を効率よく運用するためにいろんなツールを入れている方も多いかと思いますが、その場合機能が重複してしまうケースが発生しそうです。
その場合に既存のツールと共存するのか、既存のツールを捨てて datacontract を導入するのかなど検討する必要があります。
そのあたりの判断基準はきっとこれからのデータエンジニアリング界隈で色んなお話が出てくるかと思うので、導入された方は何かしら記事にしたりイベント登壇していただけますととても助かります！

この記事で予期せぬデータのトラブルを防げる方が増えることを祈っています。