dbt-privacyのAmazon Athena対応から学ぶdbtマクロ
この記事はヌーラバー真夏のブログリレー2024の17日目の記事です。
はじめに
dbt(data build tool)はデータパイプラインのELT処理のうち、T(Transform)を効率的に行うことができる便利なツールなのですが、悲しいかな、Amazon Athena(以下Athena)の公式のコネクタはありません。Communtyで開発されているAthena用のコネクタはあるので、dbtを利用すること自体は可能なのですが、dbtのpackageが対応していない場合があります。
そこで今回、dbt-privacyというデータ保護を行うpackageをAthenaに対応させてみたところ、対応する過程で、dbtのmacroの挙動を知ることができたので、まとめてみました。
dbt-privacyでのデータ保護
dbt-privacyは2024/8現在、PostgresSQL、Snowflake、BigQuery、Redshiftに対応しています。
細かい使い方については、公式ドキュメントを見ていただければと思いますが、下記のようなSQLを書くと、
select
id
,{{ dbt_privacy.hash("email", pepper_scope="project") }} as email
from hoge
以下のように変換されます。これはdbtのConnectorがAmazon Redshiftの場合の例です。
select
id
,sha2(
('' || lower(email) || sha2( ('pepperstr_jaffle_shop')::varchar,
256))::varchar, 256) as email
from hoge
処理としては、
- カラムの値を小文字化
- ペッパー文字列を生成してSha256でハッシュ化(引数や環境変数の設定によってここは変わる)
- カラムの値とペッパーを結合してSha256でハッシュ化
という流れになっています。
このようにdbtのpackageで呼び出されたメソッドは、コンパイル時にSQLの関数に書き換えられて実行されます。
dbtでのmacroの実行順序
dbt-privacyをAthenaに対応させる前に、dbtにおけるmacroの実行順について見ておきたいと思います。
公式ドキュメントによると以下のようになっていました。
- global project - default
- global project - plugin specific
- imported package - default
- imported package - plugin specific
- local project - default
- local project - plugin specific
まず1と2の global project
ですが、これはdbt-coreの中に含まれているmacroになっています。
default
というのはconnectorの種類と特定しない汎用的に定義されたmacro、plugin specific
というのは特定のConnectionに対応したmacroのことを指しています。
global project
のmacroは、dbt coreの中に定義されているので、場所でいうとこのあたりにあります。
# global project
## default
dbt/core/dbt/include/global_project/macros/
## plugin specific (redshiftの場合)
dbt/adapters/redshift/dbt/include/redshift/macros/
次に3と4の imported package
ですが、これは先程のdbt-privacyのように、あとからdbt projectに追加されたpackageに含まれるmacroを指しています。
default
と plugin specific
の区別はglobal projectの場合と同様です。
以下の場所に定義されています。
# imported package
## default
dbt_project_root/dbt_packages/[pacage_name]/macros/foobar.sql
## plugin specific (Redshiftの場合)
dbt_project_root/dbt_packages/[pacage_name]/macros/redshift_foobar.sql
最後の5、6のlocal project
は、dbt_project.ymlで定義されているmacro-paths
に格納されているもので、開発者が定義したmacroになります。。こちらも default
と plugin specific
の区別はglobal projectの場合と同様です。
# local project
## default
dbt_project_root/macros/foobar.sql
## plugin specific (Redshiftの場合)
dbt_project_root/macros/redshift_foobar.sql
この順番は「優先順」ではなく、「実行順」であることが重要です。
dbtでは、同名のmacroが複数定義されている場合、あとから読み込まれた内容で上書きされるので、
つまり、強さの順としては
- local project
- imported package
- global project
になります。
dbt-privacyのmacroを解析してみる
前置きが長くなっちゃいましたが、dbt-privacyのmacroを見てみます。
dbt-privacyにはいろんなメソッドがあるので、冒頭でも例示した、一番基本的な hash
というmacroについて細かく見てみたいと思います。
dbt-privacyの中身は下記のようなディレクトリ構成になっており、mask.sql
というファイルにhash
というmacroが定義されていました。
dbt_packages/dbt_privacy
└── macros
├── hash.sql
├── hash_unique.sql
├── mask.sql
├── mask_email.sql
├── redact_unique.sql
├── safe_mask.sql
└── utils
├── arg_validation.sql
├── docs
│ ├── generate_pepper.yml
│ └── helpers.yml
├── generate_pepper.sql
├── simple_hash.sql
├── sql_lower.sql
└── sql_quote_string.sql
{%- macro hash(
expr,
lowercase=true,
salt_expr="''",
pepper_scope="model",
pepper_persistence="permanent",
digest_size=256
) -%}
{%- if lowercase == true -%}{% set expr = dbt_privacy.sql_lower(expr) %}{%- endif -%}
{%- if salt_expr not in ("", "''") -%}
{% set salt = dbt_privacy.simple_hash(salt_expr, digest_size) %}
{%- else -%}
{% set salt = "''" %}
{%- endif -%}
{%- set pepper = dbt_privacy.generate_pepper(
pepper_scope=pepper_scope, pepper_persistence=pepper_persistence
) -%}
{{- dbt_privacy.simple_hash(salt ~ ' || ' ~ expr ~ ' || ' ~ pepper, digest_size) -}}
{%- endmacro -%}
細かなmacroの書き方についての説明は割愛しますが、冒頭で例示したhashの処理内容と比べてみると、
- カラムの値を小文字化
{%- if lowercase == true -%}{% set expr = dbt_privacy.sql_lower(expr) %}{%- endif -%}
- ペッパー文字列を生成してSha256でハッシュ化(引数や環境変数の設定によってここは変わる)
{%- if salt_expr not in ("", "''") -%}
{% set salt = dbt_privacy.simple_hash(salt_expr, digest_size) %}
{%- else -%}
{% set salt = "''" %}
{%- endif -%}
{%- set pepper = dbt_privacy.generate_pepper(
pepper_scope=pepper_scope, pepper_persistence=pepper_persistence
) -%}
- カラムの値とペッパーを結合してSha256でハッシュ化
{{- dbt_privacy.simple_hash(salt ~ ' || ' ~ expr ~ ' || ' ~ pepper, digest_size) -}}
というような対応になっていました。
2のペッパー文字列の生成と、3のカラム値のハッシュ化は、両方ともdbt_privacy内のsimple_hash
というマクロを使っていますね。
このsimple_hash
というmacroは、utils/simple_hash.sql
に定義されています。
{%- macro simple_hash(expr, digest_size=256) -%}
{{- dbt_privacy.raise_on_bad_digest_size(digest_size) -}}
{{- return(adapter.dispatch("simple_hash", "dbt_privacy")(expr, digest_size)) -}}
{%- endmacro -%}
{%- macro default__simple_hash(expr, digest_size=256) -%}
sha2( ({{ expr }})::varchar, {{ digest_size }})
{%- endmacro -%}
{%- macro postgres__simple_hash(expr, digest_size=256) -%}
encode(sha{{ digest_size }} ( ({{ expr }})::varchar::bytea), 'hex')
{%- endmacro -%}
{%- macro redshift__simple_hash(expr, digest_size=256) -%}
sha2( ({{ expr }})::varchar, {{ digest_size }})
{%- endmacro -%}
{%- macro bigquery__simple_hash(expr, digest_size=256) -%}
{{- dbt_privacy.raise_on_bad_digest_size(digest_size, ok_sizes=[256, 512]) -}}
to_hex(sha{{ digest_size }} (cast({{ expr }} as string)))
{%- endmacro -%}
#エラーハンドリングのmacroについては省略#
{%- endmacro -%}
simple_hash
から別のmacroは呼ばれていないので、どうやらこのmacroが処理の実体のようです。
simple_hash.sql
の中に adapter.dispatch
というメソッドがあるのですが、これはdbt-coreで実装されているメソッドで、profiles.ymlに定義されているconnectorの情報から、適切なmacroを探してくる処理を担っています。第1引数に対象のmacro名、第2引数に検索する名前空間を指定します。ここではdbt_privacy
が名前空間として指定されています。
ただ、第2引数に指定された名前空間は、最初に探す場所というだけで、指定された場所になければ他の場所も検索するようになっています。
adapter.dispatch
で呼び出されるmacro名は [adapter名]__[macro名]
という命名になります。たとえば、profiles.ymlに設定されているconnectorがAmazon Redshiftだった場合、dbtはredsfhit__simple_hash
というmacroを探します。
探した結果、どこにも該当するものがなければadapterとしてdefault
が設定され、ここではdefault__simple_hash
が使われることになります。
上記のコードを見ると分かるとおり、ここにはathena__simple_hash
というmacroがありません。
そのため、default__simple_hash
が使われるのですが、Athenaでは以下のような記法は対応していないためエラーになります。
sha2( ({{ expr }})::varchar, {{ digest_size }})
これがdbt-privacyがAthenaに対応していない理由になります。
Athena用のsimple_hashを追加する
前のセクションで書いたとおり、dbtはmacroを呼び出すときに指定した名前空間以外の場所も探します。
つまり、ローカルプロジェクトに定義されているmacroも探してくれるので、ローカルにAthena用のsimple_hashを追加すればOKです。
というわけで、AthenaのSQL文法に合わせて、以下のmacroを追加しました。
格納場所はdbt_project.ymlのmacro-path
の場所です。
{%- macro athena__simple_hash(expr, digest_size=256) -%}
to_hex(sha{{ digest_size }}(to_utf8(cast({{ expr }} as varchar))))
{%- endmacro -%}
これでAthenaでもdbt-privacyのメソッドを使うことができました。
あらためて処理順を書くと、以下のとおりになります。
- dbt_privacy.hashメソッドが呼び出される
- dbt_packages/dbt_privacy/hash.sqlが呼び出される
- 2からdbt_packages/dbt_privacy/simple_hash.sqlが呼び出される
- simple_hash内で、adapter.dispatchメソッドによって、connectorに合わせたmacroが検索される
- ローカルプロジェクトで定義したathena__simple_hashが使われる
まとめ
dbtのpackageには高機能なものがたくさんあり、どんどん使っていきたい気持ちになります。
一方で、自社の環境に対応していなかったり、処理をカスタマイズする必要がある場面など出てくるかもしれません。そういうときに、完全にイチから作り直すのではなくて、既存のmacroの追加や上書きなどで対応することができれば、パッケージのソースコードを有効に使うこともできると思います。
それでは。
Discussion