🧃

dbt core, Fusionの新機能、UDFsを試してみた

に公開

どうも、stable株式会社でデータエンジニアをしているmyshmehです。

2025-10-10のFusion Diaryで、UDF機能(beta)がFusionに追加されたことが発表されました。

本記事では、本機能の概要と実際使ったりコードを読んでわかったことをシェアします。

UDF機能とは

ここでいうUDF機能とは、dbt core v1.11とfusionでベータ公開された、UDF (User-defined functions)をdbtで管理できる機能です。

使い所

この機能の使い所は、以下が考えられると思います。

1. 関数的な抽象化がしたい時

これまでJinjaマクロでは、主に以下の目的でSQLの抽象化が運用されていた印象です。

  1. 独自のSQL関数を作りたい: 特定の値を統一的に扱うためのシンプルな変換やクレンジングを、より汎用的な関数として定義したい場合です。例えば、以下のような場合が当たります。
    • 空文字やN/Aなどの欠損値を統一扱い
    • 電話番号や郵便番号などの正規化処理
  2. SQLコード自体を抽象化したい: 以下のように、SQLクエリの構造そのものを生成・制御したい場合です。
    • 動的にSELECTするカラム、GROUP BYキーなどを切り替える場合
    • 複数カラムに対して一律の変換処理を繰り返し適用する場合

1の場合、Jinjaマクロでこれらを抽象化すると、恩恵を受けられるのはdbtで管理しているデータモデルのみでした。
しかし、UDFで抽象化すると、UDFはWarehouseのオブジェクトなので、さらにdbtの外部のBIツールなど、SQLが実行されるあらゆる場所で再利用可能な形で呼び出すことができます。

したがって、今後の棲み分けとしては、1をUDFが、2を引き続きJinjaマクロが受け持つのが良いのではないかと感じています。

2. 複雑なロジックを記述したい時

加えて、現在はSQLのみで記述可能ですが、別言語も対応になった時には、SQLでは書きづらい複雑なロジックを吸収するためにも、UDF機能は活躍しそうです。

使い方

dbtでUDFを定義するには、下図のように

  1. UDFのシグネチャ含むメタデータが指定されたyamlファイル
  2. UDFの内容が書かれたSQLファイル

が必要です。

コンパイルすると、warehouseに応じたUDF作成のDDLが生成されます。

作成したUDFは、任意のモデル内で、functionマクロを使って参照することができます。

select
    maybe_positive_int_column,
    {{ function('is_positive_int') }}(maybe_positive_int_column)
from {{ ref('a_model_i_like') }}

詳細に関しては、公式Docを参照ください。
https://docs-getdbt-com-git-add-udfs-doc-dbt-labs.vercel.app/docs/build/udfs

デモ

では、実際に色々なパターンのUDFを作成して、挙動を見てみましょう。

デモ環境

  • dbt version: dbt-fusion 2.0.0-preview.45
  • Warehouse: Snowflake

公式コードを試す

公式Doc記載のコードを試してみます。

functions/is_positive_int.sql
REGEXP_LIKE(a_string, '[0-9]+$')
functions/schema.yml
functions:
  - name: is_positive_int # required
    description: My UDF that determines if a string represents a positive (+) integer # required
    config:
      schema: udf_schema
      database: udf_db
    arguments: # optional
      - name: a_string # required if arguments is specified
        data_type: string # required if arguments is specified
        description: The string that I want to check if it's representing a positive integer (like "10") 
    returns: # required
      data_type: boolean # required

単純に、作成したUDFを呼び出すモデルを作成します。

models/marts/my_model.sql
with final as (
    select {{ function('is_positive_int') }}('10') as col
)
select * from final

ユニットテストとして、実行結果が確認できるようにしておきます。

models/marts/my_model.yml
unit_tests:
  - name: test_is_positive_int 
    model: my_model
    expect:
      rows:
        - { col: true }

上記コードでdbt buildを実行すると、UDF・モデルの作成、モデルのテストが成功しました。

dbt build --select +my_model --profiles-dir .dbt
dbt-fusion 2.0.0-preview.45
   Loading .dbt/profiles.yml
   Loading packages.yml
 Succeeded [  0.37s] function dev.is_positive_int (function)
    Passed [  0.64s] unit_test dev_dbt_test__audit.test_is_positive_int
 Succeeded [  0.60s] model dev.my_model (table)

====================================================================================== Execution Summary ======================================================================================
Finished 'build' successfully for target 'dev' [2.3s]
Processed: 1 model | 1 unit test | 1 function
Summary: 3 total | 3 success

my_modelのデータも、想定通りです。

COL
true

VSCEのリネージ図でも、ドキュメント通りUDFがノードとして現れました。

Jinjaマクロも使える?

ここで、UDFの定義中にJinjaマクロは使えるのか、試してみます。

全く無意味ですが、先ほどのUDFロジックをそのままJinjaマクロにしてみましょう。

functions/is_positive_int.sql
{{ is_positive_int('a_string') }}
macros/is_positive_int.sql
{% macro is_positive_int(str) %}
  REGEXP_LIKE({{ str }}, '[0-9]+$')
{% endmacro %}

実行結果

dbt build --select +my_model --profiles-dir .dbt
dbt-fusion 2.0.0-preview.45
   Loading .dbt/profiles.yml
   Loading packages.yml
 Succeeded [  0.62s] function dev.is_positive_int (function)
    Passed [  0.60s] unit_test dev_dbt_test__audit.test_is_positive_int
 Succeeded [  1.11s] model dev.my_model (table)

====================================================================================== Execution Summary ======================================================================================
Finished 'build' successfully for target 'dev' [2.8s]
Processed: 1 model | 1 unit test | 1 function
Summary: 3 total | 3 success

問題なく実行できました!

{{ is_positive_int(a_string) }}じゃだめなの?

だめです。Jinjaマクロ展開で、a_stringはUDFの引数と認識されないからです。

以下のようにa_stringをsingle quoteしないと、Jinjaマクロ展開では、is_positive_int(str)マクロで定義したstrに何も値が入りません。

functions/is_positive_int.sql
{{ is_positive_int(a_string) }}
マクロ展開結果
REGEXP_LIKE(, '[0-9]+$')

結果として、不正なSQLとしてSnowflakeからエラーが返ります。

dbt build --select is_positive_int --profiles-dir .dbt
dbt-fusion 2.0.0-preview.45
   Loading .dbt/profiles.yml
   Loading packages.yml
    Failed [  1.53s] function dev.is_positive_int (function)
error: dbt1014: Error executing materialization macro 'dbt.materialization_function_default' for function function.jaffle_shop.is_positive_int: [Snowflake] 090203 (42601): Compilation of SQL UDF failed: SQL compilation error:
syntax error line 3 at position 14 unexpected ','.
syntax error line 3 at position 14 unexpected ','.
syntax error line 5 at position 4 unexpected ')'.
(in compiled/functions/is_positive_int.sql:1:4)
(in dbt_internal_packages/dbt-adapters/macros/materializations/functions/function.sql:10:5)
(in dbt_internal_packages/dbt-adapters/macros/materializations/functions/helpers.sql:2:31)
(in dbt_internal_packages/dbt-adapters/macros/materializations/functions/helpers.sql:9:13)
(in dbt_internal_packages/dbt-adapters/macros/etc/statement.sql:14:35)
  --> compiled/functions/is_positive_int.sql:1:4

====================================================================================== Execution Summary ======================================================================================
Finished 'build' with 1 error for target 'dev' [2.5s]
Processed: 1 function
Summary: 1 total | 1 error

したがって、UDFの引数を使ってマクロを呼び出す際には、必ず当該引数をquoteでマクロに渡し、当該マクロでは{{ }}で囲って当該引数を使ってあげる必要があります。

macros/is_positive_int.sql
{% macro is_positive_int(str) %}
  REGEXP_LIKE({{ str }}, '[0-9]+$') -- {{ str }}が、a_stringに置き換わる
{% endmacro %}
a_stringをqouteしたマクロ展開結果
REGEXP_LIKE(a_string, '[0-9]+$')

つまり、ref()も使える?

任意のJinjaマクロが使えるならば、ref()も使えそうです。

functions/use_ref.sql
select count(*) from {{ ref('my_model') }}
functions/schema.yml
functions:
  # ...
  - name: use_ref
    description: a
    config:
      schema: udf_schema
      database: udf_db
    returns: # required
      data_type: number # required

問題なく作成できました!

dbt build --select use_ref --profiles-dir .dbt
dbt-fusion 2.0.0-preview.45
   Loading .dbt/profiles.yml
   Loading packages.yml
 Succeeded [  1.84s] function dev.use_ref (function)

====================================================================================== Execution Summary ======================================================================================
Finished 'build' successfully for target 'dev' [3.5s]
Processed: 1 function
Summary: 1 total | 1 success

コンパイル結果をみると、正しくマクロ展開されていました。

select count(*) from database_name.dev.my_model

VSCEでリネージ図を見てみると、ちゃんとmy_modelをUDF内で参照していることが表示されます。

気になる点

UDF自体のユニットテストはできない

加えて、執筆時点ではUDF自体をユニットテストする仕組みがない理解です。

公式Docでは、以下のようにUDFを呼ぶモデルに対して、ユニットテストを作成しています。

select
    maybe_positive_int_column,
    {{ function('is_positive_int') }}(maybe_positive_int_column)
from {{ ref('a_model_i_like') }}
unit_tests:
  - name: test_is_positive_int 
    description: "Check my is_positive_int logic captures edge cases"
    model: my_model
    given:
      - input: ref('a_model_i_like')
        rows:
          - { maybe_positive_int_column: 10 }
          - { maybe_positive_int_column: -4 }
          - { maybe_positive_int_column: +8 }
          - { maybe_positive_int_column: 1.0 }
    expect:
      rows:
        - { maybe_positive_int_column: 10,  is_positive: true }
        - { maybe_positive_int_column: -4,  is_positive: false }
        - { maybe_positive_int_column: +8,  is_positive: true }
        - { maybe_positive_int_column: 1.0, is_positive: true }

この例は問題ないですが、普通に運用しているモデルのロジックを考慮してgivenを指定する必要があるのは、まどろっこしいです。

ワークアラウンドとして、UDFテストのためだけに上記のようなテストしやすいモデルを作ることはあり得ますが、Warehouseに不要なオブジェクトを作ってしまいます。これは、分析者にとって意味のわからないテーブルが生まれかねないので、見通しがあまりよくありません。

ネイティブなUDFのユニットテスト機構が求められます...

静的解析は限定的な印象

執筆時点では、functions/*.sql配下のSQLコードは文法・意味チェックがされてない印象です。

例えば、次のような不正なREGEXP_LIKEの利用や、let's gooooooooという不正なSQL文法について、Fusionのdbt compileはエラーを返しません。

REGEXP_LIKE(a_string, '[0-9]+$', 10, 9, 8) let's goooooooo

また、is_positive_int(a_string)はstringのみ受け付けますが、呼び出し元のモデルコードで、他の型の値(e.g. 10, true)を指定しても、コンパイルエラー含むVSCEのエラーは出ません。dbt compileも成功します。

with final as (
    -- コンパイルエラーは出ない。赤線もつかない。
    select {{ function('is_positive_int') }}(10) as col
)
select * from final

したがって、現状では、Fusionを使っていても、UDFの誤った参照に気づくのはdbt runした時になってしまいます。

アプローチとしてさほど難しくない印象なので、将来的にはサポートされると思われます。

不具合

執筆時点のFusionでは、dbt build --selectでUDFを参照したモデルのみを指定すると、下記のように当該UDFのスキーマをダウンロードに失敗して警告を出します(モデル自体は作成されます)。

dbt build --select my_model --profiles-dir .dbt
dbt-fusion 2.0.0-preview.45
   Loading .dbt/profiles.yml
   Loading packages.yml
warning: dbt1014: Failed to download function schema for 'function.jaffle_shop.is_positive_int'. Setting 'static_analysis' to off. Skipping analysis for 'model.jaffle_shop.my_model': [Snowflake] 002003 (42S02): SQL compilation error:
Table 'DATABASE_NAME.DEV.IS_POSITIVE_INT' does not exist or not authorized. (SQLSTATE: 42S02, Vendor code: 2003)
warning: dbt1000: Skipping unit_tests for models with detected 'unsafe' and 'static_analysis' set to 'off': 'unit_test.jaffle_shop.my_model.test_is_positive_int'
 Succeeded [  1.64s] model dev.my_model (table)

====================================================================================== Execution Summary ======================================================================================
Finished 'build' with 2 warnings for target 'dev' [4.9s]
Processed: 1 model | 1 unit test
Summary: 1 total | 1 success

前述のデモのように、UDFも含むように--select +my_modelと指定すれば、この警告は出ません。

本バグは、誤ってUDFの情報を、describe table文で取得しようとしてることが原因と見られ、現在issueをあげて対応を依頼中です。

describe table "DATABASE_NAME"."SCHEMA_NAME"."IS_POSITIVE_INT"

https://github.com/dbt-labs/dbt-fusion/issues/890

現状の機能制限と将来性

執筆時点では、UDF機能には以下の制限がありますが、Fusionのコードを見てみると、将来的な拡充が期待できます。

  1. 言語対応はSQLのみ
  2. 返り値はスカラ値のみ

1に関しては、UDFのyamlプロパティにlanguageがあり、JavaScriptやPythonなどの他言語のサポートが期待できます。

crates/dbt-schemas/src/schemas/properties/function_properties.rs
pub struct FunctionProperties {
    pub config: Option<FunctionConfig>,
    pub data_tests: Option<Vec<DataTests>>,
    pub description: Option<String>,
    pub identifier: Option<String>,
    pub name: String,
    pub tests: Option<Vec<DataTests>>,
    pub language: Option<String>,
    pub returns: Option<FunctionReturnType>,
    pub arguments: Option<Vec<FunctionArgument>>,
    #[serde(rename = "type")]
    pub function_kind: Option<FunctionKind>,
}

2に関しては、function_kindの値としてaggregate, tableの記載が既にあり、SnowflakeでいうUDAF (User-defined aggregate function)やUDTF (User-defined table function)のサポートも期待できます。

crates/dbt-schemas/src/schemas/properties/function_properties.rs
pub enum FunctionKind {
    #[serde(rename = "scalar")]
    #[default]
    Scalar,
    #[serde(rename = "aggregate")]
    Aggregate,
    #[serde(rename = "table")]
    Table,
}

まとめ

dbt core v1.11とFusionに追加されたUDF機能について、実際に動かしながら検証してきました。

本機能により、これまでdbt内でのみ再利用可能だったJinjaマクロによる抽象化が、UDFとしてWarehouse上に永続化されることで、BIツールなど外部からも利用可能になりました。これにより、データ変換ロジックの一元管理と再利用性が大きく向上します。

大筋では、関数的な抽象化が必要な場面では積極的に活用していきたい機能だと感じました!

現時点では、UDF自体のユニットテストができない点や、静的解析が限定的である点など、いくつかの課題も見られます。ベータ版なので、この辺は改善を期待したいです。
また、コードを読む限り、今後JavaScript/Pythonなどの他言語対応や、UDAFやUDTFといった集約・テーブル関数のサポートも期待できそうです。

ここまで読んでいただきありがとうございました!

Discussion