深入解锁 dbt——Jinja、Macros 与 Packages:模板、宏和扩展包

0 阅读44分钟

到目前为止,本书已经覆盖了 models 和 snapshots 等主题,这些主题主要使用相对直接的 SQL transformations。不过,你是否曾经发现自己在写重复的 SQL code,并希望这些代码可以在 transformations 中轻松复用,而不需要每次都重写一遍?这正是 Jinja 与 dbt 结合发挥作用的地方,它为你的 SQL code 增加了可扩展的 programmatic capability。

Jinja 是一种 text-based template engine,在 Python 中用于生成 markup 或 source code。Jinja 使你能够使用 variables、filters 和 control structures,例如 for loops,在 compile time 生成 text files。在 dbt 的 context 中,Jinja 会 compile 成 SQL code。对于编写 Python 的 Data Engineers 来说,这应该非常熟悉;但对于主要编写 SQL 的 Data Analysts 来说,一开始可能会有些令人生畏。虽然 Jinja 可以做一些 advanced 的事情,但我们认为入门的 learning curve 很低。

除了为 SQL transformations 增加 programmatic capabilities,Jinja 还让你能够创建 macros。回忆第 1 章,Jinja macros 可以类比为 object-oriented 和 functional programming languages 中的 functions。本章中,我们将覆盖如何在 models 中使用 Jinja,以及如何将 reusable logic 抽象成 macros。如果你以前从未使用过 Jinja,本章会为你提供入门所需的基础知识。

虽然理解如何自己构建 macros 很重要,但在开始构建 solution 之前,最好先检查 dbt community 中是否已经有人遇到过类似问题,因为很可能已经有人遇到过。本章后面,我们会讨论如何访问 dbt package hub,以及一些可用的 useful packages。

Jinja Basics

本节旨在提供你在 dbt project 中开始探索 Jinja 世界所需的基础知识。Jinja template engine 有很多 features 你可能会感兴趣,但本节不会全部覆盖。如果你属于这种情况,建议查看官方 Jinja documentation: jinja.palletsprojects.com

Common Syntax

我们先看 Jinja 的 common syntax。Jinja 使用 curly brace characters,也就是 dbt community 中常说的 “curlies”,在 template 中定义 delimiters。在 dbt context 中使用 Jinja 时,你会遇到三种 default delimiters:

{{ ... }}
表示 expressions。Expressions 会 compile 成 string,并且经常用于引用 variables 和 macros。

{% ... %}
表示 statements。Statements 用于定义 macros、snapshots 和 custom materializations。此外,statements 也用于识别 control structures。

{# ... #}
表示 comments。Comment delimiter 中的 text 不会与 Jinja template 的其余部分一起 compile。

在本书到目前为止的内容中,你已经多次遇到 Jinja,只是可能没有意识到。例如,{{ source() }}{{ ref() }} functions 都是 Jinja expressions。dbt 使用 Jinja expressions,在 compile time 将 table references 动态放入你的 SQL code 中。除此之外,你也已经遇到过 Jinja statements。回忆上一章,每次开始一个新的 snapshot 时,都必须将 SQL code 包裹在 {% snapshot %} ... {% endsnapshot %} 中。

Expressions

Expressions 是 Jinja 最基础的概念之一。随着你越来越熟悉将 Jinja 融入 SQL code,你会在 dbt 中频繁使用它们。对 expression 最基础的解释是:在 compile time,double curlies 之间任何有效内容都会作为 string 打印到 compiled SQL files 中。至于什么算 “valid content”,我们会覆盖以下内容:

  • Literals
  • Math
  • Comparisons

虽然这不是 Jinja expressions 中可使用内容的完整列表,但我们认为这些已经足够让你在 dbt context 中使用 Jinja。接下来我们看几个不同类别的 expressions 如何 compile 的示例。虽然本节会 inline 展示这些示例,但我们也提供了一个 .sql file,里面写出了所有示例。你可以在以下路径找到该 file:

~/models/miscellaneous_chapter_resources/chapter_6/expression_examples.sql

如果你熟悉 Python,literal expressions 应该很自然,因为 literals 本质上就是 Python objects。这些 objects 包括 strings、numbers 和 lists。下面示例中的 expressions 按顺序表示这三种 object types:

{{ "This compiles to a string" }}
{{ 100 }}
{{ ['this', 'is', 'a', 'list', 'of', 'objects'] }}

如果你引用我们提供的 example file,可以根据你使用 dbt 的方式,用两种方法 compile 它。如果你使用 dbt Core,需要运行以下 command 来 compile 这些 Jinja expressions:

dbt compile --select expression_examples

随后你应该能在 dbt project 的 target subdirectory 中找到 compiled .sql file。另一方面,如果你是 dbt Cloud user,只需要打开 example file,然后点击 compile button。Compiled output 会显示在 IDE 中 .sql file 下方的 window 中。

Compiled results 看起来像这样:

This compiles to a string
100123
['this', 'is', 'a', 'list', 'of', 'objects']

除了 literal expressions,Jinja 也允许你使用 math expressions。这里我们保持简短,因为根据我们的经验,当 transformations 中需要做 math 时,我们通常会直接使用 SQL。不过,让你知道这个功能存在仍然很重要,因为你可能会遇到适合使用 math expressions 的场景。

Math expressions 中可以使用多种不同 math operations,下面示例展示如何 add、subtract 和 multiply:

{{ 19 + 23 }}
{{ 79 - 37 }}
{{ 7 * 6 }}

与所有 Jinja expressions 一样,它们仍然会 compile 成 string value。这些 math expressions 都会 evaluate 为 42。

最后一种要覆盖的 expression 是 comparison expressions。这些 expressions 会返回 boolean value,即 True 或 False。可用的 comparison operators 与 Python language 中的类似。下面是几个 comparison expressions 示例:

{{ 1 == 1 }}
{{ 1 != 1 }}
{{ 1 < 2 }}
{{ 1 > 2 }}

这些 expressions 会根据相应 logic evaluate 为对应的 True 或 False value。请记住,output 只是 "True""False" 的 string。当你使用 Jinja expressions 时,这一点非常重要:result 总是 string。

在结束本节之前,我们想提醒你:dbt 中 expressions 非常常用于引用 variables 或调用 macros。本节前面展示过你一直在使用 expressions 来调用 sourceref macros;但本章后面会展示如何构建和调用你自己的 macros,或者通过 packages 使用 open source macros。我们还没有展示如何使用 expressions 引用 variables,因为我们想先介绍如何设置 variables。下一节中,我们会展示如何设置和引用 variables。

Variables

Jinja variables 可用于存储 object,以便后续使用或操作。它们通过前面讨论过的 statement syntax 来设置。此外,variables 提供了一种方式,让我们可以在整个 template 中多次引用同一个 object;在 dbt 中,这个 template 会 render 为 .sql file。本章后面,我们会进入一些示例,展示如何使用 variables 帮助动态生成 SQL statements。现在先讨论如何设置和引用 variables。与这些示例关联的 file 路径如下:

~/my_first_dbt_project/models/miscellaneous_chapter_resources/chapter_6/variables_examples.sql

我们先看该 file 中的第一个示例,也就是 Listing 6-1,用它开始理解 variables 如何定义、如何引用,以及 referenced variable compile 后是什么样子。

要设置 variable,需要在 statement delimiter 中使用 set tag。在 Listing 6-1 中,我们展示了一个示例,将 variable foo 设置为 string literal 'bar'。虽然这里将 variable 设置为 string,但也可以设置为任何其他 valid object type,例如 number、list,或者对另一个 variable 的引用。

{% set foo = 'bar' %}
{{ foo }}

Listing 6-1:设置并引用 Jinja variable 的示例

在设置 variable foo 的 statement 之后,我们用 expression delimiter 包裹 variable。这就是使用 Jinja 引用已赋值 variable 的方式。请记住,如果你在设置 variable 之前尝试引用它,会得到 compilation error,提示该 variable undefined。不过,由于我们已经定义并引用了该 variable,你可以运行 dbt compile command;如果使用 dbt Cloud,只需点击 compile button。

Compile 这个示例之后,compiled result 中只应看到 string bar。这很好,但如果我们想在后面 override 这个 variable 呢?来看 Listing 6-2,了解如何 override variable assignment。

{% set foo = 'bar' %}
{% set foo = 'baz' %}
{{ foo }}

Listing 6-2:Jinja 中 variable reassignment 的示例

注意,从 Listing 6-1 到 Listing 6-2 只做了一个调整:添加了一个 additional set tag,将 foo variable 重新赋值为 'baz'。Compile 这个示例时,你应该只看到 string baz。当你开始将 control structures,例如 for loops,引入 Jinja-fied SQL code 时,这种 variable reassignment 会非常有用。

下一节会覆盖 control structures,但在继续之前,我们再看最后一个设置 variables 的示例。这次会使用 Jinja 中所谓的 assignment block。Listing 6-3 提供了 assignment block 的示例。它看起来与前面将 value 赋给 foo variable 的方式非常相似,但现在我们可以设置更复杂的 variables。在 dbt context 中,有时会使用这种格式,将 variable 设置为一个可以运行的 SQL query。

{% set foo %}
'bar'
{% endset %}
{{ foo }}

Listing 6-3:使用 assignment block 设置 Jinja variable 的示例

本章后面会讨论更复杂、更实用的示例。在 Jinja 中设置 variable 不应与从 dbt_project.yml 或 command line 传入 variable 混淆。我们会在本章后面讨论如何完成这件事;但目前我们只讨论在单个 file 中本地使用的 variables。

现在,查看这个示例的 compiled results。注意 Listing 6-1 的 compiled results 与 Listing 6-3 不同。在普通 assignment 中,string 周围的 quote characters 不会出现在 compiled result 中;但 assignment blocks 并不是这样。像 Listing 6-3 这样的 assignment block 会包含你列出的所有 characters。当创建 variable assignments 时,这一点非常重要,因为你需要知道是否希望 quote characters 出现在 compiled result 中。如果在我们的示例中不想让 quote characters 出现在 compiled result 中,只需要从 assignment block 中的 bar 去掉它们即可。

Conditionals and For Loops

上一节中,我们介绍了如何用 Jinja 设置和引用 variables。当你把 variables 与 Jinja 提供的 control structures 结合起来使用时,variables 会更有价值。Jinja documentation 将 control structures 定义为:

“Control structure refers to all those things that control the flow of a program – conditionals (i.e. if/elif/else), for loops, as well as things like macros and blocks.”

https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures

虽然这个定义包含多个概念,但接下来我们会聚焦 conditional if statements 和 for loops。本节中,我们会展示如何用 Jinja 实现 if statements 和 for loops。这两者都非常有用,可以帮助生成重复 SQL,也可以帮助构建 macros。本节会先关注这些 control structures 的基础,然后给出一个示例,展示如何用它们减少 SQL select statement 中的重复性。在进入示例之前,我们先定义两者:

Conditional if statements
一种 logical flow,根据 condition 是 true 还是 false,告诉 program 应该做什么。

For loops
program 中的一种 iterative process,只要 condition 为 true,就会执行。

Jinja 中的 if statement 与 Python language 非常相似。因此,它遵循同样的 if / elif / else structure。看 Listing 6-4,你会看到这三个不同 keywords 如何使用。该示例可以在以下路径找到:

~/my_first_dbt_project/models/miscellaneous_chapter_resources/chapter_6/if_statement_example.sql
{% set foo = 'baz' %}
{% if foo == 'bar' %}
    'This condition is checked first.'
{% elif foo == 'baz' %}
    'This is true!'
{% else %}
    'This is only true if all of the above are false.'
{% endif %}

Listing 6-4:Jinja if statement 示例

在前面的示例中,我们基于本章前面讨论 variable assignment 的示例继续扩展。这个示例首先将 variable foo 设置为 value 'baz'。随后,我们定义了一个包含三个 conditions 的 if statement。在这种结构中,每个 condition 会按顺序检查,直到某一个 evaluate 为 true。在我们的示例中,第二个 condition 为 true,因此当你 compile 这个示例时,对应 string "This is true!" 会被注入到 compiled .sql file 中。如果 control flows,例如 if statements,对你来说是新概念,那么可以尝试修改这个示例,做成自己的版本,从而更好地理解如何有效使用 if statements。

现在来看 Jinja for loops 的基础 syntax,以及如何使用它们简化重复的 SQL statement。For loops 用于遍历不同类型的 sequences,包括 lists、tuples 和 dictionaries。然后我们可以对 sequence 中的每个 element 执行 statement。在 Listing 6-5 中,可以看到我们首先初始化 list foo,其中包含三个 string elements。初始化这个 list 之后,就可以在 for loop 中引用它。

{% set foo = ['bar', 'baz', 'qux'] %}
{% for i in foo %}
  Current element in list: {{ i }}
{% endfor %}

Listing 6-5:Jinja for loop 示例

For loop 遵循这样的 syntactic structure:

首先从 statement block 开始。这里,我们告诉 Jinja engine 遍历 foo list 中的每个 value。Statement 中的 value i 是我们引用当前正在 iterating over 的 element 的方式。

接下来,定义对 sequence 中每个 element 应该做什么。在这个示例中,我们只是打印一个 string,告诉我们当前正在操作哪个 element。

最后,必须用 {% endfor %} statement 结束 for loop,以便 Jinja engine 知道 for loop 在哪里停止。

让我们扩展 for loops,看看如何用它们简化 Listing 6-6 中这样的 SQL statement。你可以在以下路径找到这个示例:

~/my_first_dbt_project/models/marts/operations/fct_daily_orders.sql

为了理解这个 query 在做什么,假设你被要求找出每一天每种 order status 下 placed orders 的数量。一个 order 可以处于三种 statuses 之一:placed、shipped 或 returned。基于这个 requirement,你判断合理做法是将 OrderPlacedTimestamp cast 为 date,然后在 case statement 中,当它匹配对应 OrderStatus 时,对 OrderId column 做 count。

select
  cast(OrderPlacedTimestamp as date) OrderPlacedDate,
  count(case when OrderStatus = 'placed' then OrderId end) as NumberOfOrdersPlaced,
  count(case when OrderStatus = 'shipped' then OrderId end) as NumberOfOrdersShipped,
  count(case when OrderStatus = 'returned' then OrderId end) as NumberOfOrdersReturned
from {{ ref('fct_orders') }}
group by 1

Listing 6-6:带有 repetitive pattern 的 SQL select statement 示例

可以看到,创建 case statement 并包裹在 count function 中的 pattern,会针对 database 中三种 order statuses 重复三次。你可能已经在想,可以用 for loop 来简化这个过程。查看 Listing 6-7,它与 Listing 6-6 位于同一 file 中,可以看到如何调整 query 来利用 Jinja。

{% set statuses = ['placed', 'shipped', 'returned'] %}
select
  cast(OrderPlacedTimestamp as date) OrderPlacedDate,
  {% for i in statuses %}
  count(case when OrderStatus = '{{ i }}' then OrderId end) as NumberOfOrders{{ i }}{% if not loop.last %},{% endif %}
  {% endfor %}
from {{ ref('fct_orders') }}
group by 1

Listing 6-7:使用 Jinja for loop 生成 repetitive SQL 的示例

注意在这个示例中,我们只需要写一次 count logic,但 Listing 6-6 和 Listing 6-7 compile 出来的 SQL 完全相同。对于这个示例而言,根据你的思维方式,使用 for loop 可能有点过度,因为我们只有三个 statuses 要遍历。话虽如此,很容易想象真实世界中这个 list 可能变成几十个、几百个,甚至更多。

Filters

继续我们的 Jinja overview,现在开始看 template filters。当你使用 Jinja 时,必然会遇到需要在 template compile 成 SQL 之前修改 variable 中 data 的情况。Filters 接收 variable input,并改变它的显示方式或结构。

Jinja filters 使用 | character 应用于 variables。看下面示例,了解如何应用 reverse filter:

{% set foo = 'bar' %}
{{ foo | reverse}}

Compiled result:

rab

虽然这个示例非常简单,但足以展示 Jinja filters 的基础。Filters 是一个广泛概念,我们只是想让你知道它们存在,因为在 dbt context 中它们有时很有用。在继续之前,也要让你知道 filters 可以 chain。让我们扩展前面的示例,同时应用 capitalize filter,它会将 string 首字母大写:

{% set foo = 'bar' %}
{{ foo | reverse | capitalize }}

Compiled result:

Rab

Jinja maintainers 测试并支持了大量 filters。你可以在这里查看完整 built-in filters 列表: jinja.palletsprojects.com/en/3.1.x/te…

Whitespace Control

本节中,我们将讨论 Jinja templates 中的 whitespace,以及如何控制 whitespace。先理解 Jinja 如何在 rendered document 中应用 whitespace;在我们的场景中,rendered document 始终是 .sql file。与其解释,不如通过一个简单示例展示。看下面例子,了解 document 在 compile 前后是什么样子:

{% set my_var = 'Will there be whitespace here?' %}
{# Will this comment leave whitespace? #}
{% for i in range (1) %}
  'How much whitespace will this loop leave?'
{% endfor %}

Compiled result:

  'How much whitespace will this loop leave?'

可以看到,template compile 后,rendered document 中留下了大量 whitespace。这是我们刚开始使用 Jinja 时感到意外的一点。经验法则是:Jinja delimiter 之外的所有 whitespace 都会 render 到 target document 中。这意味着在前面的示例中,前三行和最后一行 code 都留下了 whitespace。原因是每个 delimiter 前面都有一个 line break。

幸运的是,Jinja 为我们提供了清理和控制 whitespace 的方式,但要提醒你,这很容易变成一个 metaphorical rabbit hole。因此,在你开始 “fixing” dbt project 中所有 whitespace 之前,先考虑它真正增加了什么价值。

免责声明说完后,我们来看如何用 Jinja 清理 whitespace。要移除 whitespace,需要使用 minus sign -,它可以移除 leading 或 trailing whitespace:

  • 要移除 leading whitespace,在 Jinja delimiter 开头添加 minus sign:{%- ... %}
  • 要移除 trailing whitespace,在 Jinja delimiter 末尾添加 minus sign:{% ... -%}
  • 要同时移除 trailing 和 leading whitespace,则在两个位置都添加 minus sign:{%- ... -%}

现在我们理解了如何清理包含 Jinja 的 rendered documents 中的 whitespace,来看如何使用 whitespace control 清理前面的示例。假设我们希望 compiled .sql file 看起来像这样:

'How much whitespace will this loop leave?'

为了做到这一点,需要做两件事。第一,移除 string 前面的 indentation,因为那是 Jinja delimiter 之外的 whitespace,会 compile 到 final file 中。第二,使用 whitespace control character -,移除 variable assignment、comment 和 for loop 关联的所有 whitespace。可以这样修改示例:

{%- set my_var = 'Will there be whitespace here?' %}
{#- Will this comment leave whitespace? #}
{%- for i in range (1) -%}
'How much whitespace will this loop leave?'
{%- endfor %}

本节到这里结束,我们已经介绍了 Jinja 的基础知识,以及如何在 dbt context 中开始使用它。到目前为止,我们主要只是展示了如何使用 Jinja 帮助生成 repetitive select statements。接下来,我们将介绍如何构建 macros,这些 macros 可以在许多 SQL transformations 中复用。

Building Macros

本章中我们已经多次暗示 macros 是一种用 Jinja 抽象 reusable SQL logic 的方式。如果你有 functional programming 背景,或者以前使用过 SQL user-defined functions(UDFs),这个概念会非常熟悉。将 macros 融入 dbt project 有许多 practical use cases,从简单的加两个数字的 macro,到一组用于在 deployments 期间最小化 downtime 的 macros。

在本书剩余章节中,我们会使用 macros 帮助解决比本节示例更复杂的问题。建议你把本节作为起点,用来理解 macros 相关 syntax,以及它们如何工作的基础。

在开始构建 macros 之前,你应该知道 dbt 期望 macros 存储在哪里。你可能已经注意到 dbt project 中有一个名为 macros 的 directory。它是在运行 dbt init command 初始化 project 时创建的,也是 dbt 期望 macros 存储的位置。Macros 与 dbt 相关的大多数内容一样,本质上只是 .sql files。在这些 files 中,我们使用 macro statement block 定义 macros。接下来的几个 sections 中,我们会通过构建 macros 的示例,为你提供构建自己 macros 所需的工具。

Phone Number Formatter Macro Example

让我们直接进入第一个示例,构建一个 macro:它接收初始格式为 1-123-456-7890 的 phone number,并将其转换为另一种格式 (123) 456-7890。我们假设 input 始终是前面展示的第一种格式,因此不会添加额外 logic 来检查初始格式。话虽如此,macros 很灵活,如果你想尝试加入这种逻辑,完全可以。

我们已经为你定义好了这个 macro,你可以在以下路径找到它:

~/my_first_dbt_project/macros/business_logic/generic/phone_number_formatter.sql

让我们走读这个 file,理解该 macro 做了什么,以及如何在 model 中引用它。首先要指出的是,这个 macro 的实际 logic 被我们称为 macro block 的结构包裹。Macro block 为我们做三件事:

  1. 告诉 dbt 这是一个 macro,因此你写的每个 macro 都应该包裹在这个 block 中。
  2. 为 macro 提供名称。
  3. 允许你定义 macro 接收的 arguments。

在我们的示例中,macro block 是这样的:

{% macro phone_number_formatter(initial_phone_number) %}
...
{% endmacro %}

从这个示例中可以看到,我们将 macro 命名为 phone_number_formatter,并定义它接收一个 argument:initial_phone_number。Macro block 内部才是真正定义 logic 的地方。对于我们的示例,我们编写了一个简单的 SQL transformation,用来将 input 转换为本节开头提到的 phone number format。组合起来,macro 看起来像这样:

{% macro phone_number_formatter(initial_phone_number) %}
  '(' || substr({{ initial_phone_number }}, 3, 3) || ')' || ' ' || substr({{ initial_phone_number }}, 7, 9)
{% endmacro %}

在继续看如何从 model 调用这个 macro 之前,先指出 macro 内如何引用 arguments。注意,当引用 initial_phone_number argument 时,它被 double curlies 包裹。回忆前面 Jinja basics overview,这被称为 expression,是 Jinja 将 argument 或 variable 转换为 string 的方式。

现在来看如何在 transformation 中使用该 macro。首先导航到 stg_crm_customers model,路径如下:

~/my_first_dbt_project/models/staging/crm/stg_crm_customers.sql

你可能已经猜到,我们会使用这个 macro 来格式化 PhoneNumber column。为此,需要将 select statement 中的:

cus.Phone as PhoneNumber

替换为:

{{ phone_number_formatter('cus.Phone') }} as PhoneNumber

现在 model 已经使用 macro 来格式化 phone number,你可以运行 dbt compile command 查看 compiled SQL;如果使用 dbt Cloud,只需在 UI 中点击 compile button。如下 compiled SQL 所示,调用 macro 的 Jinja statement 被替换成了 phone_number_formatter macro 的实际内容:

select
...
'(' || substr(cus.Phone, 3, 3) || ')' || ' ' || substr(cus.Phone, 7, 9) as PhoneNumber
...

Note

如果你发现自己在 models 中编写 repetitive transformations,例如格式化 phone number,就应该考虑使用或创建 macro 来处理它。一如既往,在创建新的之前,先检查是否已经存在。

Return a List from a Macro Example

刚才的示例为你提供了一个基础理解:如何将 reusable SQL 放进 macro,并在 model 中调用该 macro。随着你更深入使用 dbt,会遇到很多这样的场景,但 reusable SQL 并不是构建 macros 的唯一原因。有时候,让 macro 返回一个 value、list 或其他类型 object,供你在 model 中操作也很有用。回忆本章前面 Listing 6-7 中的示例,我们展示了如何使用 for loop 生成 SQL。在那个 for loop 中,我们遍历了一个 order statuses list。

在这个示例中,我们会基于同一个例子继续构建。但不是在同一个 file 中定义 order statuses list,而是把这个 list 移到 macro 中。让我们这样定义 macro:

{% macro get_order_statuses() %}
  {{ return(['placed', 'shipped', 'returned']) }}
{% endmacro %}

在这个 macro 中,我们只是把 order statuses list 移到这里,使该 list 可以从 dbt project 中任何地方调用。为此,我们使用 return,这是 dbt 内置的 Jinja function。使用 return function 是我们第一次介绍 dbt built-in Jinja functions。本章后面会继续探索更多常用 functions。

现在这个 list 已经放入 macro 中,我们就可以在 dbt project 的任何地方一致地调用这个 list。如果这个 list 以后需要更新,只需要在一个地方更新。Listing 6-8 展示了如何调用这个 macro。这个示例与 Listing 6-7 非常相似,但我们不再需要定义 list,而是在 for loop 中直接调用 macro 来获取 list。

select
  cast(OrderPlacedTimestamp as date) OrderPlacedDate,
  {% for i in get_order_statuses() %}
  count(case when OrderStatus = '{{ i }}' then OrderId end) as NumberOfOrders{{ i }}{% if not loop.last %},{% endif %}
  {% endfor %}
from {{ ref('fct_orders') }}
group by 1

Listing 6-8:调用返回 list 的 get_order_statuses macro 的示例

Generate Schema Name Macro Example

到目前为止,我们已经展示了如何使用 macros 抽象重复 SQL,也展示了如何使用 return function 将某些 data 返回到调用 macro 的位置。本节最后一个示例中,我们想展示如何 override dbt built-in macros,从而改变 dbt project 的 behavior。到这里,你应该至少熟悉 refsource 这两个 built-in macros,因为我们在第 4 章和第 5 章都讨论过它们。虽然你可以 override 它们,但本节会聚焦另一个 macro。

回忆第 2 章,我们带你连接 dbt 到 data platform,并展示了如何设置 development environment 的 schema。你使用 dbt Core 还是 dbt Cloud,会影响在哪里设置 target schema。快速提醒一下:

  1. 对 dbt Cloud users 来说,它是在 dbt Cloud UI 的 profile settings tab 中 credentials section 设置的,如图 6-1 所示。
  2. 对 dbt Core users 来说,它是在 profiles.yml 的 schema configuration 下设置的,如下:
my_first_dbt_project:
  target: dev
  outputs:
    dev:
      ...
      schema: public
      ...

image.png

图 6-1:dbt Cloud UI 中设置 development schema 的位置

基于这一点,当你构建第 4 章的示例 models 时,回忆我们为其中几个 models 设置了 custom schemas。你可以在下面的 dbt_project.yml 中看到这些 configurations 是如何设置的。例如,我们配置 staging directory 中的所有 models 应该 compile 到名为 staging 的 schema 中:

models:
  my_first_dbt_project:
    staging:
      +schema: staging
      crm:
      furniture_mart:
    intermediate:
      +schema: staging
      finance:
      operations:
    marts:
      finance:
        +schema: finance
      operations:
        +schema: operations

不过,你可能已经注意到,当 dbt 生成这些 custom schemas 时,方式很有意思。它不是简单地将 models 生成到你定义的 custom schema 中,而是按照 <target schema>_<custom schema> 的格式生成 schema name。因此回到 staging models 的 custom schema,dbt 实际上会将这些 models render 到名为 public_staging 的 schema 中。

Note

这会根据你定义的 target schema 而变化。出于本书目的,我们将 target schema 配置为 public

通常,在 dbt development environment 中,每个 engineer 会将自己的 target schema 定义为自己的名字,或类似的 unique value。因此,每个 developer 都会有一组以自己名字,或者自己的 target schema,作为 prefix 的 custom schemas。这种 behavior 在 development environment 中是理想的,因为它允许多个 engineers 在自己的 schemas 中工作,不必担心覆盖彼此的工作。然而,它会在 production 中制造混乱的 schema names。幸运的是,这个 behavior 可以被 override。所有这些 default behavior 都来自一个名为 generate_schema_name 的 macro,我们可以通过在自己的 project 中定义一个同名 macro 来 override 它的工作方式。

当然,在编辑它之前,先看看这个 included macro 的默认未编辑代码可能很有用。默认代码如下:

{% 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 %}

Note

你可以在以下路径找到这个 macro 并跟随操作:

my_first_dbt_project/my_first_dbt_project/macros/configuration/generate_schema_name.sql

对于这个示例,假设你有两个 environments:devprod,并且希望在每个 environment 中以不同格式生成 schema names:

  • 在 production 中,将 custom schema names render 为已配置的 custom schema name。不要在前面加 target schema。
  • 在所有其他 environments 中,忽略 custom schema name,并将所有内容 materialize 到 target schema name 中。

如果遵循这个 pattern,就可以清理 production 中的 schema names,同时仍然为每个 developer 保留一个 dedicated space。为此,需要在 macros subdirectory 中创建一个 .sql file。我们已经为你生成了这个 file,但请记住 file name 可以是任意的。此外,常见实践是 macros 和 files 一对一,并让 file name 与 macro name 匹配。这样在理解 macro file 中存储了什么时,housekeeping 会非常容易。不过,你也可以在单个 file 中存储多个 macros。但如果选择这种方式,我们提醒你一定要有 standard naming convention 和 organizational structure,否则 macro files 可能变得很混乱。

接下来,在这个 .sql file 中,需要定义一个 macro,名称为 generate_schema_name。不同于 .sql file 的名称,这个 macro 必须精确命名为这个名字,因为当 project compile 时,dbt 会搜索任何 override built-in dbt macros 的 macros。为了创建前面描述的 environment-aware schema behavior,应将 generate_schema_name 创建为 Listing 6-9 所示。

{% macro generate_schema_name(custom_schema_name, node) -%}
  {%- set default_schema = target.schema -%}
  {%- if target.name == 'prod' and custom_schema_name is not none -%}
    {{ custom_schema_name | trim }}
  {%- else -%}
    {{ default_schema }}
  {%- endif -%}
{% endmacro %}

Listing 6-9:override default custom schema behavior 的 macro 示例

如果拆解它,这个示例相对简单。首先,如下面示例所示,我们定义 variable default_schema,将它设置为 current environment 的 target schema。下一节会更详细讨论 target function。

{%- set default_schema = target.schema -%}

接下来,开始一个 if statement。第一组检查的 conditions 是判断 target name 是否为 prod,以及是否定义了 custom schema。下一节同样会进一步讨论 target function。现在只需要知道,target.name 表示当前被配置为 target 的 profile:

{%- if target.name == 'prod' and custom_schema_name is not none -%}

目前,我们一直在 development environment 中运行所有 dbt transformations,所以这个 condition 始终 resolve 为 false。本书后面会讨论在 production 中运行 dbt,到那时会看到这个 macro 如何影响 production schema names。不过,如果现在是在 production 中运行,这个 macro 会满足我们的 requirement:不再将 target schema name 作为 prefix 添加到 custom schema 前面。相反,只使用 custom schema name,并且 trim whitespace,确保 schema name 中没有 whitespace。这是通过将 return value 设置为以下内容完成的:

{{ custom_schema_name | trim }}

最后,在这个 if statement 中,对于所有其他 conditions,会通过设置以下内容返回 default schema:

{%- else -%}
  {{ default_schema }
{%- endif -%}

这个 block 会满足第二个 requirement:无论是否设置 custom schema,所有 models 都 materialize 到 developer 的 target schema 中。

这只是如何 override dbt 默认 custom schemas behavior 的起点,我们鼓励你调整这段 logic,使其适合你的 environment setup。例如,teams 常常会有 QA environment,而且让该 environment 更类似 production environment 可能是值得的。不过,在当前 macro 状态下,QA environment 会更像 development environment。

在继续之前,我们想指出,如果你希望 dbt 按照前面描述的 logic 构建 schemas,可以将 macro 简化为:

{% macro generate_schema_name(custom_schema_name, node) -%}
  {{ generate_schema_name_for_env(custom_schema_name, node) }}
{%- endmacro %}

这里不是像 Listing 6-9 那样定义 logic,而是调用另一个 built-in macro,名为 generate_schema_name_for_env。这能工作,是因为这个 built-in macro 中的 logic 与 Listing 6-9 中定义的完全相同。虽然可以这样做,但我们在参与的项目中通常没有这样做,因为如果以后需要改变 generate_schema_name_for_env macro 的 behavior,它会增加一层 complexity。不是说不能这么做,而是说在选择如何处理 custom schema names 时,应考虑 trade-offs。

dbt-Specific Jinja Functions

到目前为止,我们已经展示了使用 templated SQL 所需的基础 Jinja skills,并介绍了构建和使用 macros 的不同 use cases。为了将这些学习提升到下一层,我们想介绍一些 dbt 提供的 Jinja functions。接下来的几个 sections 中,我们会走读一些在过去构建 dbt projects 时发现非常有用的 functions。不过,本书范围内不可能覆盖所有可用于 project 的 Jinja functions,所以建议查看 dbt documentation,了解完整可用 functions 列表。

Note

你可以通过 dbt documentation 访问完整 dbt Jinja functions 列表: docs.getdbt.com/reference/d…

Target

上一节 Listing 6-9 中,我们展示如何使用 macros 改变 dbt 生成 custom schema names 的 behavior 时,已经介绍过 target function。不过,当时我们只是让你相信它会工作,并没有详细说明 target function 或它如何工作。

target function 允许你访问与 data platform connection 相关的信息。你可以访问 target 的不同 attributes,例如 name、profile name 或 schema。回忆一下,target 是 dbt 连接到 data platform 所需的 connection information。根据我们的经验,target function 最常用于根据 project 当前运行在哪个 environment 中,改变 dbt project 的 behavior。稍后会展示一个示例。现在先看有哪些 attributes 可用,以及如何访问它们。

目前,所有 dbt users 都可以访问 target 的五个 attributes,无论使用哪个 adapter 连接 data platform:

target.profile_name
表示当前用于连接 data platform 的 profile name。如果不确定这个 name 是什么,可以在 profiles.ymldbt_project.yml 中找到。

target.name
表示 target name。虽然不总是如此,但 target name 往往与 environment name 同义。

target.schema
表示 dbt 会在 database 中生成 models 的 schema name。

target.type
表示用于连接 data platform 的 adapter。在我们的示例中,它会是 snowflake,但也可能是 bigquery、databricks 等。

target.threads
表示你配置 dbt 使用的 threads 数量。

如前所述,无论你使用哪个 adapter,这五个 attributes 都可用。对于某些 adapters,还有额外 attributes 可能有用。我们不会逐一讨论每个 adapter,而是聚焦 universally available attributes。如果你想了解如何使用自己 adapter 特定的 target function attributes,可以参考 dbt documentation。

下面示例展示如何根据 target name 改变 individual model 的 materialization type:

{{
  config(
    materialized = 'view' if target.name == 'dev' else 'table'
  )
}}

这里,我们使用 target.name 检查 target name;如果 target name 是 'dev',就将该 model materialize as view;其他所有 target names 下,该 model 应 materialize as table。

This

this function 对访问 model 在 database 中呈现的完整 reference 很有用,也就是说它会 compile 成 database.schema.table 格式的 string。你也可以分别访问这三个 attributes:

{{ this.database }}
{{ this.schema }}
{{ this.identifier }}

你可能会想,为什么不直接使用 ref function 引用当前 model 的 database identifier?原因是不可以,因为 dbt 会由于 circular dependency 而报错。所以我们可以使用 this function 作为 workaround。

Note

Circular dependency 指 model 引用自身。应避免 accidental circular dependencies,但当它们是 intentional 时,例如 incremental logic 中,这可能很有价值。

正如第 4 章讨论的,可以使用 this function 构建 incremental models。看下面示例,了解如何通过基于 timestamp 过滤来使用该 function 限制 incremental model run 中被拉入的 records:

{{
  config(
    materialized = 'incremental'
  )
}}
select
    *
from {{ ref('foo') }}
{% if is_incremental() %}
  where updated_at >= (select max(updated_at) from {{ this }})
{% endif %}

虽然前面的示例是 this function 的常见 use case,但它并不是唯一用途。你也可以使用这个 function 构建 logging,或者在 hook 中调用 model。例如,可以在 post-hook 中使用这个 function,对 resulting materialized table 或 view 进行某种操作,例如处理 hard deletes。下一章会讨论 hooks 以及如何使用它们。

Log

log function 可用于在每次执行 dbt command 时向 log file 写入 message。可选地,你也可以让 message 写入 terminal。我们经常发现这个 function 对 debugging messages 很有用。下面示例展示了如何生成一条 log message,它会将已执行 dbt command 的 current invocation id 写入 log file,并将 message 打印到 terminal。通过移除 info argument 或将其设置为 False,可以让这个 statement 不打印到 terminal。

{{ log('invocation id: ' ~ invocation_id, info=True) }}

Tip

在 Jinja syntax 中,tilde(~)用于 string concatenation。

Adapter

本书中我们经常提到 adapter 这个术语。在幕后,adapter 是 dbt 用来与 database 来回通信的 object。adapter Jinja function 是 adapter object 的 abstraction,允许你将 adapter class 的不同 methods 作为 SQL statements 调用。你可以调用很多不同 methods,但这里我们只展示一个示例:获取 existing relation 中的 columns list。更完整的 adapter methods 列表可以参考官方 dbt documentation。

下面示例展示如何使用 get_columns_in_relation method 返回 fct_orders table 中的 columns list。一旦有了这份 columns list,你就可以按任意方式操作它。

Note

这个 method 返回一个 Column objects list。每个 object 都有多个 attributes,包括 column、dtype、char_size、numeric_size 等。

{% set columns = adapter.get_columns_in_relation(ref('fct_orders')) %}
{% for column in columns %}
      {{ log('Column object: ' ~ column, info=True) }}
{% endfor %}

如果 run 或 compile 这个示例,会看到 terminal 中打印出类似以下内容的多行:

Column object: SnowflakeColumn(column='ORDERID', dtype='NUMBER', char_size=None, numeric_precision=38, numeric_scale=0)

虽然能够通过 Column object 访问所有这些 metadata 很有用,但出于我们的目的,我们只想要 column names list。可以修改初始示例来实现这一点:

{% set column_objects = adapter.get_columns_in_relation(ref('fct_orders')) %}
{% set columns = [] %}
{% for column in column_objects %}
    {% do columns.append(column.column) %}
{% endfor %}
{% for column in columns %}
    {{ log("Column: " ~ column, info=True) }}
{% endfor %}

如果现在 run 或 compile 这个修改后的示例,应该会看到每个 column name 被打印到 terminal,类似这样:

Column: ORDERID
...
Column: ORDERPLACEDTIMESTAMP

Var

var function 用于访问 project 的 dbt_project.yml file 中设置的 variable value。这对于设置在 project 多处使用的 variables 很有用。它有助于提升 project maintainability,因为如果某个 variable 的 value 需要修改,可以在 dbt_project.yml 中更新,然后 change 会在整个 project 中传递下去。

幸运的是,var function 很灵活,可以在 .sql files 和 .yml files 中使用。下面示例中,我们会展示如何在定义 source 引用的 database 时,在 sources.yml file 中使用这个 function。我们没有把这个示例包含进 example code repo,因为我们没有使用 sources,但你可以很容易将这个示例应用到真实场景中。

首先需要在 dbt_project.yml 中定义 variable,如下:

...
vars:
  raw_db: raw
...

假设有一个 sources.yml file,看起来像这样:

version: 2
sources:
  - name: raw
    database: raw
    schema: public
    tables:
      - name: orders
...

为了替换 sources.yml 中 static database reference,我们会这样替换 database config:

version: 2
sources:
  - name: raw
    database: "{{ var('raw_db') }}"
    schema: public
    tables:
      - name: orders
...

前一个示例要求你在 dbt_project.yml 中声明 variable,但你实际上也可以在 model 中引用一个尚未声明的 variable。不过,在下一个示例中,我们会使用 var function 的第二个 optional argument,也就是 default。这个第二个 argument 允许你在 variable 未声明时设置一个 default value。本质上,它确保 dbt compile project 时始终有一个 value 替换 variable。

下面示例中,我们会在 incremental model 中使用 var function,这样可以运行 backfill jobs,而不需要 fully refresh 整张 table。快速复习第 4 章,incremental 是一种 model materialization strategy,用于将 records merge 到 destination table,而不是每次运行 dbt 都重建 table。常见实践是在 model 的 incremental runs 中使用 is_incremental function 添加 where clause 来限制 dataset,通常基于 timestamp field,以提升 performance。

不过,当在 data warehouse 中 incrementally build tables 时,常常会因为新增 column、修改 logic 或其他原因,需要 backfill data。dbt 提供了 --full-refresh flag,可以用于 fully refresh incremental models,但这可能带来高昂计算成本。作为 fully refreshing models 的替代方案,下面示例会允许你在 run time 通过 command-line argument 传入一个 date,从而对 model 执行 backfill 到某个 date。我们会使用 var function 的 default argument,这样如果没有通过 command-line argument 传入 date,incremental logic 所依据的 date 就可以使用 model 自身的 maximum date。

先从一个简单 incremental model 示例开始。这个示例应该看起来很熟悉,因为它与我们在 this function 中使用的示例相同。注意,在 is_incremental block 中,我们使用 subquery 从 destination table 获取 max updated_at timestamp,并基于该 timestamp 过滤 select statement 的 results。

{{
  config(
    materialized = 'incremental'
  )
}}
select
    *
from {{ ref('foo') }}
{% if is_incremental() %}
  where updated_at >= (select max(updated_at) from {{ this }})
{% endif %}

前面示例中的代码很适合按 schedule incrementally loading destination table,但总会出现 schema changes,或者需要处理 data quality issue 的时候。在这种场景下,最简单选项是做 full refresh。但如果你有 performance 或 cost 等 constraints,导致不能这么做呢?这时可以利用下面的修改版示例:我们用 max updated_at timestamp 作为 variable 的 default value,同时允许在 run time 传入 date:

{{
  config(
    materialized = 'incremental'
  )
}}
{% if is_incremental() %}
  {% if execute %}
    {% set query %}
      select
        max(updated_at)
      from {{ this }}
    {% endset %}
  {% endif %}
  {% set max_updated_at = run_query(query).columns[0][0] %}
{% endif %}
select
  *
from {{ ref('foo') }}
{% if is_incremental() %}
  where updated_at >= '{{ var("backfill_date", max_updated_at) }}'
{% endif %}

在初始示例和修改后示例之间,有两个变化需要拆解。第一个是 query 顶部新增的 Jinja,如下示例所示。它使用 query 从当前 model select max updated_at timestamp。我们先设置一个名为 query 的 variable,然后使用 run_query Jinja function 运行它。run_query function 返回一个 agate table,因此必须 index 返回的 agate table,才能真正获取我们想要的 timestamp value。

Note

如果你想了解更多关于 Agate 的内容,可以阅读以下 documentation,但我们不建议在这个主题上花太多时间: agate.readthedocs.io/en/latest/a…

{% if execute %}
  {% set query %}
    select
      max(updated_at)
    from {{ this }}
  {% endset %}
{% endif %}
{% set max_updated_at = run_query(query).columns[0][0] %}

接下来,注意我们也修改了 incremental runs 中会添加到 query 的 where clause。我们使用 var function,而不是像原始示例中那样使用 subquery。如果名为 backfill_date 的 variable 被提供,它的 value 会放入 where clause;否则默认使用 max_updated_at timestamp。在实践中,只有当我们想做 backfills 时,才会提供 backfill_date variable。现在看可以运行哪些 commands,以及 compiled SQL 会是什么样。

可以用以下 command 运行刚才 review 的示例:

dbt run --select fct_foo

这个 command 会使用我们设计的 default incremental logic 运行 model。因此,max updated at timestamp 会从 destination table 中取出,并填入 where clause。现在来看一个 command,用于对某个 point in time 执行 backfill。假设我们想 reprocess date 在 2023-01-01 或之后的 records,可以使用:

dbt run --select fct_foo --vars {"backfill_date":"2023-01-01"}

这个 command 与第一个基本相同,但包含了 command-line argument --vars。这个 command 允许你以 YAML dictionary 形式传入 key-value pairs,其中 key 是 variable name,value 是你希望 variable 被设置成的内容。因此,前面的 command 会生成如下 compiled SQL:

select
  *
from {{ ref('foo') }}
where updated_at >= '2023-01-01'

Note

这只会是 incremental runs 的 compiled SQL。如果这是 model 的 initial run,where clause 会被排除。

Env Var

我们要分享的最后一个 dbt Jinja function 是 env_var function,它可用于在 dbt 中访问 environment variables。Environment variables 对于在不同 environments 中动态管理 dbt project configuration、从 code 中移除 secure credentials,以及向 dbt artifact files 添加 custom metadata 很有用。

我们会讨论如何在 dbt Cloud 中设置 environment variables。不过,对于 dbt Core users,我们假设你已经在自己的机器上设置好了所需 environment variables。设置 machine environment variables 的方式太多,并且在不同 operating systems 之间差异过大,因此无法逐一讨论。

Tip

env_var function 可以在任何可以使用 Jinja 的地方使用,包括 profiles.ymlsources.yml.sql files 等。

对于 dbt Cloud users,创建的任何 environment variables 都需要以 DBT_ 为 prefix。你可以导航到 Environments page 并点击 Environment Variables tab,查看已有 environment variables。如果需要设置新的 environment variable,需要点击 Add Variable button。图 6-2 展示了这个页面存在一个 environment variable 时的样子。

image.png

图 6-2:dbt Cloud Environment Variables page

如果你使用 dbt Core,并没有硬性要求 environment variables 必须以 DBT_ 为 prefix,但我们建议这样做,并且本节剩余部分会遵循这个 pattern。我们建议遵循这个 pattern,原因如下:

  • 它能帮助你清楚地识别机器上哪些 environment variables 是用于 dbt project 的。
  • 如果未来选择从 dbt Core 迁移到 dbt Cloud,会更容易。
  • 可以轻松控制哪些 files 可以访问 environment secrets。

回忆本章前面,我们展示过如何使用 var function 将 database name 插入 sources.yml file 中的数据源配置。它可能看起来像这样:

version: 2
sources:
  - name: raw
    database: "{{ var('raw_db') }}"
    schema: public
    tables:
      - name: orders
...

如果所有 environments 中都使用同一个 raw database,这很有用。但如果需要使用两个 raw databases 来构建 dbt models 呢?

  • raw:用于 production environment
  • raw_dev:用于 development environment

这就是使用 env_var function 访问 environment variables 的机会。假设你有一个名为 DBT_RAW_DB 的 environment variable,并设置了适当 value,可以将 sources.yml 修改为使用 environment variable,而不是 project variable。更新后的 configuration 如下:

version: 2
sources:
  - name: raw
    database: "{{ env_var('DBT_RAW_DB) }}"
    schema: public
    tables:
      - name: orders
...

前一个示例有助于基于当前 environment 动态配置 project。除此之外,env_var function 也常用于确保 secrets 不被 hardcoded 到 dbt project 中。你可能希望拉入 environment variables 的常见 secrets 包括:

  • Username
  • Passwords
  • Deployment tokens

如果你使用 dbt Core,并按照第 1 章说明设置 project,很可能 database credentials 直接存储在 profiles.yml 中。对于 development environment,这可能不是大问题,但仍然可以更新配置,将 username 和 password 改为存储在 environment variables 中。为此,首先创建两个 environment variables,分别命名为 DBT_ENV_SECRET_USERDBT_ENV_SECRET_PASSWORD,并设置相应 value。然后将 profiles.yml 中的 user 和 password configs 更新为:

your_project_name:
  target: dev
  outputs:
    dev:
      ...
      user: "{{ env_var('DBT_ENV_SECRET_USER') }}"
      password: "{{ env_var('DBT_ENV_SECRET_PASSWORD') }}"
      ...

注意,这两个 environment variables 都使用了 prefix DBT_ENV_SECRET_。虽然这个 prefix 不是必需的,但对于 environment secrets 来说很理想,因为 dbt 会确保所有使用该 prefix 的 environment variables 只能被 profiles.ymlpackages.yml files 访问。这有助于确保 environment secrets 不会意外显示在 database 或 dbt artifacts 中,例如 manifest.json file。

Useful dbt Packages

虽然理解如何将 Jinja 和 macros 融入 dbt project 非常重要,但如果你发现自己需要用它们构建复杂 processes,我们建议先检查 dbt community 中是否已经有人解决了同样的问题。本节中,我们会介绍 dbt package hub,它是一个 dbt packages 集合,你可以将这些 packages 引入自己的 project。

在 dbt package hub 中,你会发现由 dbt 和 community members 发布的 projects。可用 packages 覆盖范围很广,从 common utilities 集合,到 transformations,再到 logging。本节剩余部分中,我们会分享一些可用 packages 的 details。虽然有些 packages 解决更 niche 的问题,但根据我们的经验,下面这些 packages 已经证明适用于广泛类型的 projects。因此我们会聚焦这个小范围 subset,但请记住,你还有更多 packages 可用。

最后,由于 packages 并不是 dbt 本身的一部分,并且可能由 community 维护,因此我们会保持较高层级的讨论。相比分享 individual examples,我们更希望让你了解每个 package 能为你做什么。然后希望你能判断哪些 packages 对自己的 project 增加 functionality 有用。此外,dbt Slack community 和 dbt Discourse 是非常宝贵的资源,可以学习当前 dbt users 如何实现 macros 和 packages。

Add a Package to Your Project

在分享任何可用 packages 之前,我们先简要说明如何向 project 添加任意 package。首先,你希望添加到 project 中的 packages list 需要定义在 packages.yml file 中。

Note

packages.yml file 应与 dbt_project.yml 位于同一 directory。

你可以用几种 methods 定义想向 project 添加哪些 packages。出于本书目的,我们只展示其中两种 methods 的示例,包括:

  • 从 package hub 添加 packages
  • 使用 git URL 添加 packages

要从 package hub 添加 package,需要在 packages.yml file 中使用 packageversion configurations。例如,如果想向 project 添加 dbt Utils package 和 Codegen package,你的 packages.yml 应该像这样:

packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.0.0", "<1.1.0"]
  - package: dbt-labs/codegen
    version: [">=0.9.0", "<1.0.0"]

注意,我们将每个 version 定义为 range,而不是 specific version。这允许我们自动拉取任何 patch updates。不过,如果你更喜欢列出 specific version,也可以。

你也可以使用 git URLs 拉取 packages。如果你有一个 internal package 想拉入 project,或者某个 package 在 GitHub 上可用但不在 package hub 中,这会很有用。这个示例中,我们展示如何使用 git URLs 拉取 dbt Utils 和 Codegen,但请记住,对于 package hub 中已有的 packages,这不是推荐方法:

packages:
  - git: "https://github.com/dbt-labs/dbt-utils.git"
    revision: 1.0.0
  - git: "https://github.com/dbt-labs/dbt-codegen"
    revision: 0.9.0

注意在前面示例中,我们改为使用 gitrevision configurations 来拉取这些 packages。git config 只是 git repository 的 URL;revision config 表示 tag 或 branch name。此外,注意我们必须指定 revision,因为直接从 git 拉取 packages 时不能定义 range。

dbt Utils

dbt Utils package 是最常用的 dbt packages 之一。这个 package 包含许多有用 macros,用于 SQL generation、generic tests 和其他 helper functions。该 package 中的 macros 被设计为跨许多不同 adapters 工作。因此,使用该 package 中的 macros 可以简化你的 code,并让它在不同 SQL dialects 之间可移植。

根据我们的经验,该 package 中最常用的一些 macros 包括:

generate_surrogate_key
向 model 添加一个 new column,用作 warehouse surrogate key。该 macro 接收一个或多个 columns 的 list,并返回 MD5 hash。

date_spine
提供生成 date spine 所需的 SQL。这个 macro 对生成 calendar table 或建模 subscription type data 很有用,在这些场景中,你需要在日期范围内每天一行。

pivot
一种将 row values pivot 成 columns 的简化方式。

union_relations
用于 unioning 相似但不完全相同的 relations。例如,可以使用这个 macro union 两张 tables,即使它们有不同 columns,或 columns 顺序不同。

当然,这不是 exhaustive list。如果你想了解该 package 以及还有哪些 macros 可用,建议阅读该 package 的 documentation。

Codegen

随着你更多使用 dbt,很快会发现 YAML 既是 curse,也是 blessing。它是 blessing,因为 YAML 让 dbt 能管理 models 之间的 relationships 和 lineage,并用于生成 documentation。但它也是 curse,因为 YAML 不会自己写,而且从零编写可能很耗时。

幸运的是,这正是 Codegen package 发挥作用的地方。这个 package 中有几个轻量 macros,你可以用 dbt run-operation command 从 command line 执行。该 package 中的一些 macros 包括:

generate_source
基于你提供的 arguments 生成 YAML,可复制到 sources.yml file 中。

generate_base_model
为你生成 SQL,可用于 base / staging models。

generate_model_yaml
基于你提供的 arguments 生成 YAML,可复制到任何用于记录和测试 models 的 .yml file 中。

dbt Project Evaluator

这个 package 用于评估你的 project 是否遵循 dbt 定义的 best practices。该 package 会 parse 不同 components,以判断:

  • Model best practices
  • Test coverage
  • Documentation coverage
  • File and naming structure
  • Performance

如前所述,开箱即用时,该 package 会根据 dbt Labs 的 dbt projects best practices 来评估你的 project。虽然 dbt Labs 对 best practices 有扎实观点和建议,但它们的 rules 未必适用于每个 project 或 organization。该 package 的 developers 意识到了这一点,因此允许你 override 与 checks 相关的 variables,也允许完全 disable checks。

此外,虽然这个 package 在 local development environment 中运行很有用,但在 Continuous Integration(CI)pipeline 中运行它更有价值。将该 package 作为 CI pipeline 中的 pass / fail check 非常直接。第 10 章讨论在 production 中运行 dbt 时,我们会展示如何做到这一点。

dbt Artifacts

我们讨论的前三个 packages 都由 dbt Labs 构建和维护;最后两个 packages 则由 dbt community members 构建和维护。

这个由 Brooklyn Data 维护的 package 会 parse dbt project graph 中的 nodes,并在 warehouse 中的 data mart 中构建 fact 和 dimension tables。生成的 models 包括关于 models、sources、seeds 等的 dimensional data,也包括 invocations 等 fact 或 event data。

这个 package 非常适合用于监控 project 的整体健康状况、确定 test failure rates,以及基于可用 data 做任何你能想到的分析。目前,生成的 data mart 中包含的 models 有:

  • dim_dbt__current_models
  • dim_dbt__exposures
  • dim_dbt__models
  • dim_dbt__seeds
  • dim_dbt__snapshots
  • dim_dbt__sources
  • dim_dbt__tests
  • fct_dbt__invocations
  • fct_dbt__model_executions
  • fct_dbt__seed_executions
  • fct_dbt__snapshot_executions
  • fct_dbt__test_executions

dbt Expectations

最后一个我们想分享的 package 是 dbt Expectations package,它包含许多 generic data quality tests。这个 package 很大程度上受到 Python package Great Expectations 的启发。

通过使用这个 package,你可以访问多个 categories 中的 data quality tests,例如:

  • Range checks
  • String matching checks
  • Robust table shape checks

我们总是会向 dbt users 提到这个 package,因为我们认为 data quality 是 data 和 analytics engineering 中最重要的方面之一。更重要的是,这个 package 让你能够极其轻松地向 project 添加 robust generic tests,而不需要担心构建 generic tests 的复杂性。虽然这个 package 不会覆盖每个 use case 的 test,但我们建议在开始构建自己的 generic test 之前,始终先检查这里。

Summary

本章覆盖了与 Jinja、macros 和 dbt packages 相关的大量主题。我们首先概览了基础 Jinja skills,这些技能能够让你有效地将 Jinja 融入 dbt projects。虽然我们没有覆盖 Jinja 的每一个深入方面,但确实覆盖了基本 syntax 和 structures,例如 variables、control flows 和 loops。

此外,我们提供了一些示例,展示如何实际使用 Jinja,让 SQL code 更 DRY(Don’t Repeat Yourself)。不过,我们要提醒一句:有效但节制地使用 Jinja。把 Jinja 用在 SQL code 的每个地方很诱人,但始终要问自己:“这会简化我的 code,还是让下一个 developer 更难理解?” 如果它没有让下一个 developer 更容易理解,就应该三思。

覆盖 Jinja 基础之后,我们提供了几个相对简单的示例,展示如何将 repeatable logic 抽象成 macros。回忆一下,dbt 中的 macros 等同于其他 programming languages 中的 functions。虽然本节覆盖的大多数示例都比较基础,但应该已经为你提供了开始为可能更复杂的 use cases 开发 macros 所需的 foundational knowledge。

进入下一节时,我们分享了 dbt 内置的 Jinja functions。这些 functions 很适合直接在 model files 和你构建的 macros 中调用。我们覆盖的一些 functions 包括 targetthisenv_var。我们分享的 built-in functions 列表,是我们在使用 dbt 开发时根据经验发现常用且有用的 functions。不过,dbt documentation 中还有更多 built-in Jinja functions 可供查找。

本章最后,我们介绍了将 dbt packages 导入 project 的概念。Packages 本质上就是已经开放给 community、可供大家在自己 projects 中使用的 dbt projects。我们坚定认为,在构建自己的 custom macros 之前,你应该先看看是否已有 package 可以帮助你解决问题。我们简要讨论了一些由 dbt 和 dbt community 维护的 packages。我们有意没有对这些 packages 做过多细节展开,因为它们往往比 dbt Core 的 fundamentals 变化更频繁。因此,我们不希望你读到过时信息。如果你对其中任何 package 感兴趣,建议阅读它们各自最新的 documentation。

下一章中,我们将讨论 hooks。这包括在 dbt invocation 的开始或结束运行 processes。此外,我们也会展示如何在 models 之前或之后运行 hook。