SQL 对于将 raw data 转换为下游消费者可使用的有价值 models 非常有用,但你一定会遇到一些时候,需要运行 dbt out of the box 并不支持的 ad hoc SQL。例如,你可能需要:
- 管理 compute layer 的 size 或 capacity
- 应用 masking policies 或 row access policies
- 管理 database parameters
这正是 hooks 可以派上用场的地方,它们可以帮助你管理 administrative tasks。不同于 dbt project 中的许多 resources,hooks 可以使用超出简单 SELECT statement 之外的 SQL commands,这就打开了很多可能性。本章中,我们会将所有讨论的 operations 都称为 hooks,但你要理解,我们实际指的是三类 operations:
Pre-/post-hooks:用于在某些 dbt nodes 执行之前 / 之后运行 SQL query。
On-run-start/end:用于在某些 dbt commands 执行之前 / 之后运行 SQL query。
Run operation:一个 dbt command,用于运行 macro。这个 command 在第 6 章 “Codegen” section 中简要提到过,本章会更详细展开。
本章将通过描述和示例介绍 hooks 如何工作。通过本章,你将理解 hooks 如何为 dbt project 的运行方式增加 flexibility。不过与此同时,我们也会讨论 best practices:什么时候代码应该放在 hook 中,什么时候应该放在 model 中。
Pre-hooks and Post-hooks
Pre-hooks 和 post-hooks 是 dbt configurations,可用于处理 dbt out of the box 不支持的 boilerplate code 或 database administration tasks。特别需要注意的是,它们允许你执行不只是 SELECT statements 的 commands。理解 pre-hooks 和 post-hooks 的一个简单方式是:它们分别是在以下 resources 执行之前或之后运行的 SQL statements:
- Seeds
- Models
- Snapshots
与其他类型 configurations 一样,你可以在 .sql 和 .yml files 中定义 pre-hooks 和 post-hooks。当然,seeds 是例外,因为它们是 .csv files,因此 hooks 只能在 .yml files 中定义。下面示例展示了在 .sql file 的 config block 中定义 pre-hook 和 post-hook 是什么样子:
{{
config(
pre_hook="Some SQL goes here!",
post_hook="Some SQL goes here!"
)
}}
select
...
或者,你也可以在 .yml file 中定义这些 hooks。例如,你可能希望某个 hook 对某个 directory 中的所有 models 运行,那么可以在 dbt_project.yml 中这样定义:
models:
my_first_dbt_project:
...
marts:
+pre-hook: "Some SQL goes here!"
+post-hook: "Some SQL goes here!"
前面这两个代码块展示了如何配置一个 pre-hook 或 post-hook,使其运行且仅运行一条 SQL statement:只需要将该 statement 放在 quotes 中,把它转换为 string。不过,hooks 也提供了灵活性,可以执行多条 SQL statements,或者执行某个 macro 返回的 SQL。本节中,我们会走过几个 practical examples,分别展示这些 hook 配置方式。
Note
注意,model-level config 使用 underscore _ 作为分隔符,而 YAML config 使用 hyphen - 作为分隔符。这是刚接触这种 syntax 的人很常遇到的错误。
接下来的 sections 中,我们会带你看几个 practical examples,你可以在自己的 dbt projects 中实现 pre-hooks 和 post-hooks 时参考。第一个示例是一个 pre-hook,用于 change database roles;第二个示例是一个 post-hook,用于应用 Snowflake masking policy。虽然我们会单独展示这些示例,但需要记住,一个 model 可以同时关联 pre-hooks 和 post-hooks。所以,不要以为你只能二选一。
我们分享的示例在实践中有用,但它们只是 hooks 能做什么的冰山一角。与本书其他示例一样,我们使用的是 Snowflake,因此部分 operations 会体现 Snowflake-specific syntax。不过,这些概念应该可以迁移到不同 data platforms,例如 BigQuery、Databricks 等。
最后,在开始探索示例之前,我们提醒你:实现 hooks 前一定要认真思考。Hooks 非常灵活,允许你运行任何想运行的 SQL statement(s)。因此,如果没有仔细思考 hooks 的用途,很容易陷入 antipatterns。
例如,没有任何东西阻止你用 model 之前运行的 pre-hook 来构建 table 或 view,但这在 dbt 中显然是 antipattern,因为这个 pre-hook statement 很容易独立成为一个 upstream model。在使用 hook 之前,你应该先问自己:是否有 dbt 的其他 feature 可以实现你想运行的这段代码?
Change Database Role with a Pre-hook
在这个示例中,假设你正在做一个 project,其中要 transform 的 data 分成两类:sensitive 和 nonsensitive。你必须处理的 sensitive data 是 Human Resources(HR)department 的数据,其中包含 Personal Identifiable Information(PII)和 Protected Health Information(PHI)。Nonsensitive data 则是组织中的其他所有数据,例如 orders、payments、shipments 等。
假设到目前为止,你的 dbt project 只需要 transform nonsensitive data,但现在有一个新需求,需要处理一些 HR data。然而,你发现自己遇到一个情况:dbt 当前配置使用的 role 无权读取任何 sensitive data,也就是本例中的 HR data。另一个 separate database role 拥有访问 HR data 的权限,但它只能访问 HR data。图 7-1 提供了这两个 roles access rights 的基本可视化。
图 7-1:按 role 划分的 source data access
应对这些 requirements 有多种方式,包括但不限于:
- 将 HR data source 的访问权限授予 Transformer role。
- 创建一个新的 dbt project,只 transform HR data。
- 创建一个使用 HR_Transformer role 的 new target。
- 在现有 dbt project 中构建 HR data models,并在这些 models 开始之前,通过 pre-hooks 切换到 HR_Transformer role。
出于示例目的,我们假设前三种 solutions 不符合本次任务 requirements。因为示例使用 Snowflake,我们可以轻松 change database roles,因此剩下第四种方案:使用 pre-hook 在 model 运行前切换 database role。
Caution
使用 hooks 切换 roles 存在限制,包括无法使用该 role 对 model 运行 tests。因此,在真实场景中,最好采用前面列出的前三种 solutions 之一。话虽如此,这个示例仍然会展示 pre-hooks 如何工作,以及它们提供的 flexibility。
这个 solution 的实现相当直接,我们会展示两种配置这个 pre-hook 的方式。第一种方式是在 model-level configurations 中实现 pre-hook。在这个示例中,见 Listing 7-1,我们定义了一个 pre-hook,使用 Snowflake 的 use role command,将 role 从 Transformer 切换为 HR_Transformer。
{{
config(
pre_hook="use role HR_Transformer;"
)
}}
select
...
Listing 7-1:使用 pre-hook 为单个 model change database role 的示例
虽然这能让前面定义的 model 使用 HR_Transformer role,但对于 project 中其他所有 models,dbt 会默认回到你配置 project 时使用的 role。如果处理 sensitive HR data 的 requirements 全部由这一个 model 满足,那么这个示例很好用。但如果有多个 models 都需要使用 HR_Transformer role 呢?有两个选项:
- 将相同 pre-hook 添加到每个 model 的 config block。
- 将 pre-hook config 移动到
dbt_project.ymlfile。
虽然选项一可以工作,但我们不推荐这样做,因为你最终会重复自己 N 次,其中 N 是需要使用这个 pre-hook 的 models 数量。这并不理想,因为如果未来需要修改或移除这个 pre-hook,你就必须更新每一个使用该 configuration 的 model。
幸运的是,选项二解决了这个问题,同时允许你继续遵循 DRY code 原则。要实现选项二,只需要从 individual models 的 config block 中移除 pre-hook,并将 pre-hook 添加到 dbt_project.yml file 中,如 Listing 7-2 所示。
models:
my_first_dbt_project:
...
marts:
...
hr:
+pre-hook: "use role HR_Transformer;"
Listing 7-2:使用 pre-hook 为指定 directory 中所有 models change database role 的示例
现在,hr directory 中的所有 models 都会有一个 pre-hook,用来运行切换到 HR_Transformer role 的 command。
Mask Sensitive Data with a Post-hook with Multiple SQL Statements
在这个 post-hooks 示例中,我们继续使用 sensitive HR data 这个设定。假设在 transform HR models 之后,我们需要对 target tables 或 views 应用 masking policy,使包含 sensitive data 的某些 fields 对没有正确权限的人不可见。
在这个示例中,我们会使用 Snowflake Dynamic Data Masking feature 的 syntax。我们假设已经存在一个名为 hr_sensitive_mask 的 dynamic masking policy,并会将这个 policy 与 dbt post-hooks 结合使用,确保它被应用到正确的 models 上。对 Snowflake users 来说,masking policy 可能类似 Listing 7-3。
create or replace masking policy hr_sensitive_mask as (val string)
returns string ->
case
when current_role() in (HR_Analytics) then val
else '**********'
end;
Listing 7-3:Snowflake masking policy 示例
Note
请参考你自己 data platform 的 documentation,了解它可能具备的数据 masking 能力,以及如何设置。
查看图 7-2 可以看到,HR_Analytics 和 Analytics roles 的 requirements 都应该允许访问 dbt 生成的任何 HR target tables 或 views。关键区别在于这两个 roles 访问这些 database objects 中 data 的方式。HR_Analytics role 应该拥有完整的、unmasked 的 data access,而 Analytics role 应该只有 limited、masked 的 data access。
图 7-2:按 role 划分的 target database object access
如果我们将 Listing 7-4 中的 masking policy 应用于任何包含 sensitive Human Resources data 的 dbt models,就可以满足图 7-2 中定义的 access requirements。为此,我们会使用一组 pre-hooks。这个示例会展示如何一次实现多个 pre-hooks。
假设 HR data mart 中唯一包含 sensitive data 的 table 是 model dim_employees。dbt materialize 的 target table 中有三个 fields 包含 sensitive PII:
full_namesocial_security_numberdate_of_birth
为了展示如何使用 post-hooks 执行 multiple SQL statements,我们会使用 separate statements 将 masking policy 应用到这些 columns 中的每一个。由于只有一个 model 包含 sensitive data,所以我们会在 dim_employees model 的 model level 应用 post-hook configuration。Listing 7-4 展示了如何通过向 post-hook configuration 传入 SQL statements list 来实现这一点。
{{
config(
post_hook=[
"alter table if exists dim_employees modify column full_name set masking policy hr_sensitive_mask;","alter table if exists dim_employees modify column social_security_number set masking policy hr_sensitive_mask;","alter table if exists dim_employees modify column date_of_birth set masking policy hr_sensitive_mask;"
]
)
}}
select
...
Listing 7-4:展示如何在 post-hook 中运行 multiple SQL statements 的示例
正如上一章提到的,每当你实现某个其他人可能已经解决过的 process 时,我们建议你始终先检查是否已有 packages。在这个例子中,有一个名为 dbt_snow_mask 的 package 可以帮助你设置和维护 Snowflake masking policies。
On-Run-Start and On-Run-End
上一节中,我们走过了几个如何使用 pre-hooks 和 post-hooks 的示例。但正如你会记得的,这类 hooks 是在 individual model 之前或之后运行的。这个功能对 tables 或 views 这类由 models materialized 出来的 database objects 执行 administrative activities 很有用。但根据我们的经验,也有一些时候,你可能需要在 project 开始运行和 / 或结束运行时执行某些 ad hoc SQL。
幸运的是,on-run-start 和 on-run-end hooks 可以在 dbt_project.yml file 中定义,正是用于这类场景。目前,这些 hooks 会在以下 dbt commands 的开始和结束时运行:
dbt seeddbt rundbt snapshotdbt builddbt testdbt compiledbt docs generate
本章前面讨论 pre- 和 post-hooks 时,我们覆盖了如何直接将 SQL 传入 hooks。因此本节会聚焦于将 macros 与 on-run-start 和 on-run-end hooks 结合使用,以运行我们需要的 SQL。on-run-start 和 on-run-end hooks 与 pre-hooks 和 post-hooks 类似:它们可以运行直接作为 string 传入的 SQL;如果需要,也可以返回 macro 的结果并以这种方式运行 SQL。所以请记住,如果你需要把 pre-hooks 和 post-hooks 的 SQL 抽象为 macro,也可以这样做。
本节中,我们会走过两个示例,一个 on-run-start,一个 on-run-end,并且这两个示例会相互配合。目标是使用 on-run-start hook 在 target schema 中生成一张 table,用于存储 log metadata。之后,我们会创建一个 macro 来生成 insert statements,将 log metadata 写入这张新建 table,并通过 on-run-end hook 调用这些 macros。
On-Run-Start Example
在这个示例中,我们首先构建一个 macro,它会通过 dbt_project.yml file 中的 on-run-start hook 运行。如果你回忆第 6 章,我们可以在 dbt project 的 macros directory 中的任何位置,通过添加包含 Jinja macro statement 的 .sql file 来定义 macros。你可以在 Listing 7-5 中看到我们创建的 macro,也可以在本书 git repo 的以下路径查看:
https://github.com/Apress/unlocking-dbt
{% macro create_logs_table(table_name) %}
create table if not exists {{ target.database }}.{{ target.schema }}.{{ table_name }}
(
invocation_id string,
node_unique_id string,
node_type string,
result_status string,
started_at timestamp,
completed_at timestamp
)
{% endmacro %}
Listing 7-5:创建 table 以记录 dbt run results 的 macro
可以看到,这个 macro 会创建一张 table,除非它已经存在。该 table 有六个 columns,可定义为:
invocation_id:与每次 dbt command invocation 关联的 unique identifier。
node_unique_id:dbt project 中每个 node 的 unique string,例如 model.my_first_dbt_project.fct_orders。
node_type:executed node 对应的 type,例如 model、snapshot、seed 或 test。
result_status:executed node 对应的 result,例如 pass、fail、success、error 或 skipped。
started_at:node execution started 的 timestamp。
completed_at:node execution completed 的 timestamp。
这是一个非常简单的 macro,用于生成 create table statement。但请注意,该 macro 接收一个 argument:table_name,用于定义 log table 的名称。我们不需要在这个 macro 中使用 run_query function,因为 dbt 会执行 macro 返回的 SQL,只要将它添加到 dbt_project.yml 中的 on-run-start configuration 即可,如 Listing 7-6 所示。
name: 'my_first_dbt_project'
config-version: 2
...
on-run-start: ["{{ create_logs_table('dbt_logs') }}"]
...
Listing 7-6:通过 on-run-start configuration 运行 macro 的示例
现在 macro 已经添加到 on-run-start configuration 中,每次使用本节前面讨论过的 commands invoke dbt 时,create_logs_table macro 中的 DML 都会运行。注意,该 macro 被 quotation 包裹;这样做是因为幕后 on-run-start config 期望接收 string value。因此,通过将 macro 包裹在 quotes 中,macro 中的 DML 会作为 string 返回,并由 on-run-start config 执行。
Note
我们使用 list 将 macro 传给 config,但你也可以只传一个 single string。我们这样做只是为了以后更容易添加 additional hooks。
完成后,你可以 invoke 一个 dbt run 来确认它是否工作。例如,如果执行 dbt run,你应该会在 console 中看到几行类似 Listing 7-7 的内容。你应该能在 console 靠近顶部的位置找到这些 lines,它们会出现在任何 seeds、models 或其他 nodes 被执行并记录到 console 之前。
12:00:00 Running 1 on-run-start hook
12:00:00 1 of 1 START hook: my_first_dbt_project.on-run-start.0 ... [RUN]
12:00:00 1 of 1 OK hook: my_first_dbt_project.on-run-start.0 ... [SUCCESS 1 in 0.59s]
Listing 7-7:invoke dbt 后显示 on-run-start info 的 console output 示例
通过这个示例,你现在应该理解:on-run-start configuration 的工作方式,是在 dbt invocation 开始时运行一个或多个 SQL statements。就像我们在这个示例中所做的,SQL 可以以 macro 的形式传给 on-run-start。现在我们已经有了生成 log table 的 process,接下来进入使用 on-run-end configuration 向这张 table 插入 records 的示例。
On-Run-End Example
与前一个示例类似,我们会使用 macro 为 hook 生成 SQL。不过这一次,我们希望这个 hook 在每次 dbt invocation 结束时运行。如上一节末尾提到的,这是通过使用 on-run-end configuration 实现的。在深入构建用于将 log records 插入 log table 的 macro 之前,先讨论如何访问要写入 log table 的 metadata。
回忆上一节,我们创建 log table 时定义了六个 columns:invocation_id、node_unique_id、node_type、result_status、started_at 和 completed_at。第一个 field invocation_id 最容易访问,因为它在 dbt 的任何地方都可以作为 Jinja function 使用。要获取 current invocation identifier,只需要这样调用 function:
'{{ invocation_id }}'
其余三个 fields 则通过一个特殊 variable 访问,这个 variable 名为 results,它只在 on-run-end hooks 中可用。从这个 object 中,你可以访问当前 dbt invocation 中每个 node 相关的 metadata,包括:
- Compile and execution timing
- Node unique ids
- Console log messages
- Execution results
这绝不是 results object 中可用 information 的完整列表,但该 object 确实包含我们 log table 所需的数据。如果你好奇 results object 中还有哪些 info 可以访问,建议查看 target directory 中的 run_results.json artifact,或者使用 {{ log() }} Jinja function 将 results object 打印到 console。
现在你知道从哪里访问 log table 所需 metadata,开始构建将 records 插入其中的 macro。该 macro 见 Listing 7-8,也可以在本书 git repo 的以下路径找到:
https://github.com/Apress/unlocking-dbt
{% macro insert_log_records(table_name) %}
{% do log(results, info=True) %}
{% for result in results %}
{% set query %}
insert into {{ target.database }}.{{ target.schema }}.{{ table_name }}
values(
'{{ invocation_id }}',
'{{ result.node.unique_id }}',
'{{ result.node.resource_type }}',
'{{ result.status }}',
{% if result.timing %}
'{{ result.timing[1].started_at }}',
'{{ result.timing[1].completed_at }}'
{% else %}
null,
null
{% endif %}
)
{% endset %}
{% do run_query(query) %}
{% endfor %}
{% endmacro %}
Listing 7-8:将 records 插入由 create_logs_table macro 定义的 log table 的 macro
Listing 7-8 中的 macro 可以拆成四个部分。第一部分完全 optional,主要是为了让你看到 results object 中有哪些 data 可用。我们可以使用 log function 将整个 results object 打印到 console:
{% do log(results, info=True) %}
不过,你可以注释掉它,或者完全删除,这样每次调用该 macro 时就不会把整个 results object 打印到 console。接下来,我们使用 Jinja statement block 开始一个 for loop,遍历 results object:
{% for result in results %}
...
{% endfor %}
如果你已经探索过 results object 的 data structure,可能会注意到它返回一个 list,其中每个 value 是另一个名为 RunResult 的 object,对应当前 dbt invocation 中的每个 node。实际上,遍历 results object 的每一次 iteration,都是在操作一个 single model、snapshot、test 等。
这个 macro 的最后两部分发生在遍历 results object 的每一次 iteration 内。首先,生成一条 insert statement,并赋值给名为 query 的 Jinja variable。当然,这个 variable 可以叫任何你喜欢的名字,但为了 simplicity 和 clarity,我们使用 query。如 Listing 7-9 所示,在这个生成的 insert statement 中,我们访问前面提到的 invocation_id function,而其他所有要插入 logs table 的 values 都来自 results object。
{% set query %}
insert into {{ target.database }}.{{ target.schema }}.{{ table_name }}
values(
'{{ invocation_id }}',
'{{ result.node.unique_id }}',
'{{ result.node.resource_type }}',
'{{ result.status }}',
{% if result.timing %}
'{{ result.timing[1].started_at }}',
'{{ result.timing[1].completed_at }}'
{% else %}
null,
null
{% endif %}
)
{% endset %}
Listing 7-9:用于向 log table 插入 records 的 query
每个 value 都对应于 results object 中的相应位置。虽然它们都很容易理解,但我们想特别说明与 timing array 相关的两个 values。首先,我们使用 if statement 确保 timing array 中有 data,因为如果没有这个 check,而 array 为空,就会抛出 error。其次,注意我们访问的是 timing array 的第二个 value,也就是 index 1,因为我们想记录 node execution 的 start 和 end time。如果访问第一个 value,也就是 index 0,那么实际记录的是 node compilation 的 start 和 end time。虽然这也是有用 metadata,但并不是我们想在这张 table 中记录的内容。
最后,存储在 query variable 中的 insert statement 会通过 run_query function 在 do block 内运行,如下所示:
{% do run_query(query) %}
现在,理解这里实际发生了什么很重要,因为它可能不是你一开始预期的。基于我们对 hooks 的理解,包括 on-run-end hooks,我们知道它们会运行直接传给它们的 SQL,或者通过 macro 返回给它们的 SQL。但由于我们在这个 macro 中使用了 run_query function,所以实际上不会向 on-run-end hook 返回任何 SQL 供其运行。相反,我们将 on-run-end hook 用作每次 dbt execution 结束时访问 insert_log_records macro 的手段。这在真实世界中很有用,因为它意味着你不总是需要从 macros 返回 SQL 给 hook,而是可以直接在 macro 内执行 SQL。这在插入 records 到 log table 这类场景中很理想,因为你需要运行一系列相似但略有不同的 SQL statements。
现在 macro 已经构建完成,并且你理解它在做什么,要实现它,只需要在 dbt_project.yml file 中调用该 macro,如 Listing 7-10 所示。为了保持 YAML file 整洁,我们将 on-run-end hook 直接放在 on-run-start hook 下方。
name: 'my_first_dbt_project'
config-version: 2
...
on-run-start: ["{{ create_logs_table('dbt_logs') }}"]
on-run-end: ["{{ insert_log_records('dbt_logs') }}"]
...
Listing 7-10:从 on-run-end hook 调用 macro 的示例
现在 hook 已经配置好,你可以运行一个 dbt command 来检查它是否工作。例如,可以运行 dbt run,你应该会在 terminal 中看到 on-run-start 和 on-run-end hooks 都成功执行。on-run-end hook message 会在所有 models、tests 等结束后记录到 console,应该类似前面图 7-7 中看到的 on-run-start message。
一旦在 console 中收到 success message,就可以切换到 database,并使用类似 Listing 7-11 的 query 查询生成的 log table,其中 dbt_logs 替换为你使用的 table name。
select
*
from dbt_logs
limit 10
;
Listing 7-11:用于查询生成的 log table 的 SQL 示例
探索这些 data 后,你可能会注意到,每当 result_status = 'error' 时,started_at 和 completed_at fields 都是 null。这完全符合预期,是因为生成 insert statements 时使用了 Jinja if statement 检查 timing array。通过本节两个示例,你现在应该对 on-run-start 和 on-run-end hooks 的基本工作方式、results object 是什么,以及什么时候使用它,有了扎实理解。
在继续之前,我们想指出,在真实 use case 中,你可能希望限制这些 logging macros 只在 production 中运行。通常 development environment 中不需要这种 robust logging。我们可以对 Listing 7-10 中的 hook definitions 做一个小修改,使其只在 target name 等于 "prod" 时运行。当然,如果你的 production target 命名为其他内容,需要相应更新:
name: 'my_first_dbt_project'
config-version: 2
...
on-run-start: ["{% if target.name == 'prod' %} {{ create_logs_table('dbt_logs') }} {% endif %}"]
on-run-end: ["{% if target.name == 'prod' %} {{ insert_log_records('dbt_logs') }} {% endif %}"]
...
Supplementary Hook Context
通过 on-run-start、on-run-end、pre-hook 和 post-hook 示例,你现在已经具备必要知识,可以开始构建自己的 processes,使它们在 dbt node execution 的不同节点运行。不过,一旦开始在 project 的多个地方添加 hooks,就还需要理解一些关于 hooks 的额外 context。第一点是 pre-hooks 和 post-hooks 相对于 database transactions 的 behavior;第二点是 hooks 的 order of operations。先看 transactions,以及实现 hooks 时什么时候需要考虑它们。
Transactions
我们预期你已经对 database transactions 有一些理解。简而言之,transactions 是一个非常有用的 database concept,它允许你 isolate individual units of work。在一个 transaction 中,一个 isolated unit of work 可以由一系列 DML、DDL 或其他 SQL command types 组成。Transaction 的关键是,每个 step 会一起 committed 或 reverted;换句话说,transactions 是一个 all-or-nothing process。
某些 dbt adapters,特别是 Redshift 和 PostgreSQL,允许你控制 pre-hooks 和 post-hooks 是在与 model 本身相同 transaction 中执行,还是在 separate transaction 中执行。理解 transactions 在提交 database changes 时是 all-or-nothing 之后,根据 use case,有些时候能够控制 pre-hooks 和 post-hooks 运行在哪个 transaction 中会很有用。默认情况下,使用 transactions 的 adapters 会在当前运行 model 的相同 transaction 中执行 hooks。
Note
dbt documentation 特别指出:对于默认不使用 transactions 的 adapters,不应使用接下来示例中的 syntax。这包括 Snowflake、Databricks / Spark 和 BigQuery。
你可以在 model .sql file 顶部的 config block 中配置 hooks 在 separate transaction 中运行,也可以在 dbt_project.yml file 中配置。如果你只想为某个 model 中的 hooks 配置 transaction behavior,就需要在该 model 的 config block 中设置 configuration。这可以通过两种方式实现:使用 dictionary 和使用 built-in helper macros;但两种方式产生的结果相同。Listings 7-12 和 7-13 分别展示了两种方法的 syntax。
{{
config(
pre_hook={
"sql": "Put a SQL statement here!",
"transaction": False
},
post_hook={
"sql": "Put a SQL statement here!",
"transaction": False
}
)
}}
Listing 7-12:使用 dictionary 在 model file config block 中配置 hook transaction behavior 的示例
{{
config(
pre_hook=before_begin("Put a SQL statement here!"),
post_hook=after_commit("Put a SQL statement here!")
)
}}
Listing 7-13:使用 built-in helper macros 在 model file config block 中配置 hook transaction behavior 的示例
当然,你也可以在 dbt_project.yml file 中配置 hook transaction behavior,如 Listing 7-14 所示。
name: 'my_first_dbt_project'
config-version: 2
...
models:
+pre-hook:
sql: "Put a SQL statement here!"
transaction: false
+post-hook:
sql: "Put a SQL statement here!"
transaction: false
Listing 7-14:在 dbt_project.yml file 中使用 dictionary format 配置 hook transaction behavior 的示例
Order of Operations
现在我们已经覆盖了如何实现不同类型 hooks,接下来花点时间理解 hooks 的执行顺序。首先看 project 中 hooks 的 order of operations。之后,我们会回过头讨论将 packages 导入 project 如何影响 hooks 运行顺序。
Hooks 的 order of operations 如图 7-3 所示,flow 从 on-run-start hook 开始。
图 7-3:Hook order of operations
可以看到,on-run-start 和 on-run-end hooks 从逻辑上被放在 operations order 的开始和结束位置。关于 pre-hooks 和 post-hooks,可能让人惊讶的是,YAML files 中定义的 hooks 会先于 node config block 中定义的 hooks 运行,因为 model configurations 的 priority order 恰好相反:model-level configurations 优先于 YAML-defined configurations。不过,这实际上是因为 YAML 会在 nodes,也就是 models、snapshots 等之前被 parsed。因此,YAML 中定义的 hooks 会先运行,这也是合理的。
再次参考图 7-3,注意与 post-hooks 相关的两个 blocks 有 shaded background。我们这样做是因为 post-hooks 有 unique behavior,你应该知道:如果与 post-hook 关联的 node 发生 failure,那么该 post-hook 不会运行。幸运的是,即使存在 node failures,on-run-end hook 仍会运行。这也是为什么我们在构建将 dbt results 记录到 table 的示例时使用 on-run-end hook,而不是 post-hook。可以想象,如果使用 post-hook,就永远不会在 log table 中看到 failure data 的 records。
Warning
Post-hooks 在 node failure 之后不会运行。
Run Operation
本章中,我们已经讨论了运行不同 variations of hooks 的多种 approaches,但前面覆盖的每种 hook 都会在某个 dbt invocation 的某个阶段运行,例如通过 dbt run、dbt snapshot 或 dbt test 这样的 command。这些在各自 context 中都很有用,但有时你可能想执行一些与 dbt project 相关的 code,却不想真正运行任何 nodes。
这时 dbt run-operation command 就派上用场了,它允许你在不运行任何 models 的情况下运行一个 macro。这是 dbt 中一个非常强大的 component,有很多利用机会。过去我们曾用这个 command 执行 macros,将 production data copy 到 individual developer databases、创建 database clones 等。Community 中也有很多很棒的 macro 示例,它们非常有用,但不一定需要作为 model builds 的一部分运行。它们更像是你偶尔想运行的 one-off solutions。
根据我们的经验,run-operation command 最常见的 use case 之一,是与 Codegen package 结合使用。第 6 章中我们简要讨论过 Codegen package,并提到过 run-operation command 的使用。现在花点时间使用这个 package,来理解如何使用这个 command。
在这个示例中,我们会使用 Codegen package 中的 generate_model_yaml macro,为第 4 章创建的 fct_orders table 生成 YAML 的初始内容。为此,你首先需要确保已将 Codegen package 添加到 project 的 packages.yml file,并通过运行 dbt deps command 刷新 project dependencies。
安装好 Codegen 后,可以通过 dbt run-operation command 调用 generate_model_yaml macro,为 fct_orders table 生成 YAML。为此,将 Listing 7-15 中的 command 输入 command line 或 dbt Cloud IDE,并运行它。
dbt run-operation generate_model_yaml --args '{"model_names": ["fct_orders"]}'
Listing 7-15:使用 dbt run-operation command 调用 macro 的示例
运行这个 command 后,你应该会看到类似下面的 results 打印到 console:
12:00:00 Running with dbt=1.X.X
12:00:00 version: 2
models:
- name: fct_orders
description: ""
columns:
- name: orderid
description: ""
...
可以看到,从 console 打印出的 messages 来看,dbt 所做的只是执行我们调用的 macro 内容,并打印结果。重要的是要理解,run-operation command 不会执行 models、tests,甚至不会执行 on-run hooks。
虽然使用 Codegen package 中的 macros 是熟悉 run-operation command 的好方式,但它并没有真正提供关于 run-operation command 幕后如何工作的信息。让我们处理另一个常见场景,构建一个 macro 来解决问题,并使用这个 command 运行该 macro。
正如本书反复讨论的,使用 dbt 的 teams 通常会为每个 developer 设置一个 development schema。例如,你可能有三人团队:Andrea、Jeremy 和 Martin。每个 developer 都会有一个标准格式为 developer_custom_schema 的 development schema,例如 andrea_product。
不过,每个 developer 可能连续几周甚至几个月不处理 project 的某些 components,那么如何确保他们始终在尽可能接近 production 的 data 上工作?第一个也是最简单的 solution,是当 developer 觉得需要让自己的 data catch up to production 时,运行完整 dbt project。对于三人团队来说,这可能还可以,但当团队扩展到几十甚至上百人时,这会迅速把 cloud compute costs 推到天花板。
幸运的是,对 Snowflake users 来说,我们可以利用 Zero-Copy Cloning 快速 snapshot production tables,并将它们 clone 到 developer schema(s)。Zero-Copy Cloning feature 是一种快速且 cost-effective 的方式,可以让 development environments 达到与 production 相同的 state。这个 process 会 clone production tables 的 metadata,并在开始时共享 storage;但一旦你开始操作 cloned tables,就会使用 separate storage,因此不必担心意外覆盖 production data。
Note
我们不会详细覆盖 Snowflake Zero-Copy Cloning,但鼓励你参考 Snowflake documentation 做进一步研究。对于其他 databases 的 users,也建议看看你的 database 是否提供类似 cloning 的 feature。
出于这个示例目的,我们假设使用该 macro 的 project 已配置 schema naming convention:production schema names 只包含 custom schema,不包含 prepend 的 default target。关于如何实现这种 behavior 的示例,可以回看第 6 章 Listing 6-9。
为实现这一点,我们会创建一个名为 clone_prod_to_dev 的 macro;示例代码可以在本书 git repository 中找到,路径为:
~/my_first_dbt_project/macros/dev_env_setup/clone_prod_to_dev.sql
你也可以在 Listing 7-16 中看到这个示例。
{% macro clone_prod_to_dev(from_database) %}
{% set clone_queries %}
select
'create or replace table ' ||
'{{ target.database }}' || '.' || '{{ target.schema }}' || '.' || table_name
clone || table_catalog || '.' || table_schema || '.' || table_name || ';' as query
from {{ from_database }}.information_schema.tables
where table_type = 'BASE TABLE'
{% endset %}
{% set clone_queries = run_query(clone_queries) %}
{% for index in range(clone_queries|len) %}
{% do run_query(clone_queries[index][0]) %}
{% set current_position = index + 1 %}
{{ log(current_position ~ ' of ' ~ clone_queries|len ~ ' tables cloned to dev.', info=True) }}
{% endfor %}
{% endmacro %}
Listing 7-16:用于将 production tables clone 到 developer schema 的 macro
真正运行这个 macro 很简单,可以使用下面的 run-operation command,只需要将 from_database argument 改成你的 production database 名称:
dbt run-operation clone_prod_to_dev --args '{"from_database": "analytics"}'
虽然运行很简单,但让我们把 macro 拆成两部分,理解它到底发生了什么,以及为什么它非常适合与 run-operation command 搭配使用。这个 macro 的第一部分如下,我们创建了一个名为 clone_queries 的 variable,它是一个 dynamic SQL query,实际上是从 production database 的 information_schema 中查询。一旦这个 variable 被设置,我们使用 do function 运行存储的 query,生成需要运行的 queries list,以 clone 所有 production tables:
{% set clone_queries %}
select
'create or replace table ' ||
'{{ target.database }}' || '.' || '{{ target.schema }}' || '.' || table_name
clone || table_catalog || '.' || table_schema || '.' || table_name || ';' as query
from {{ from_database }}.information_schema.tables
where table_type = 'BASE TABLE'
{% endset %}
{% set clone_queries = run_query(clone_queries) %}
如果你将这个 dynamic query 中的 variables 替换为实际 values,并在 Snowflake 上运行,会看到类似表 7-1 的结果。可以看到,这个 dynamic query 实际上生成了一组 queries,为 production database 中每张 table 生成一条 query。
表 7-1:clone_prod_to_dev macro 中动态生成并运行的 queries 示例
| query |
|---|
| ‘create or replace table dev.developer_schema.table_x clone analytics.product.table_x;’ |
| ‘create or replace table dev.developer_schema.table_y clone analytics.finance.table_y;’ |
| ‘create or replace table dev.developer_schema.table_z clone analytics.product.table_z;’ |
这个 macro 的第二部分会对我们需要运行的 queries 集合执行 for loop,将 individual tables clone 到 developer schema 中。在每次 iteration 内,我们再次使用 do function 运行每条 query,然后将 status 打印到 console。我们遍历 clone_queries variable 的 length,这样每 clone 一张 table 后,都能轻松打印 process 进度。
虽然这个 macro 有几个 caveats,例如使用 Snowflake、使用单一 developer schemas,以及 views 不能被 cloned,但这里的关键 takeaway 是:当调用 run-operation command 时,必须在 macro 中显式执行 SQL。这是必要的,因为 run-operation command 会将返回的 SQL 只当作 string,而不会替你运行这段 SQL。
Summary
本章中,我们介绍了可以在 dbt projects 中使用的多种 hooks。首先讨论的是,hooks 适合运行那些不适合放入其他 dbt features,例如 model 或 configuration 中的 ad hoc SQL。我们先讨论了 node-level hooks,也就是 pre-hooks 和 post-hooks,它们分别在 node 执行之前和之后运行。我们使用切换 database roles 的示例展示了 pre-hooks 如何工作,并使用给 table 应用 masking policy 的示例展示了 post-hook。
虽然 node-level hooks 对操作 single database object 很有用,但有时在 dbt execution 开始或结束时运行某种 command 或 query 也很有价值。这正是 on-run-start 和 on-run-end hooks 发挥作用的地方。为了更好理解这类 hooks 如何工作,我们展示了两个相互配合的示例:创建并向 log table 写入 data,使我们可以记录 dbt errors、successes 和其他感兴趣的 metadata。
关于这四种不同 hooks,我们坚定认为,理解 hooks 的设计是否在 dbt project 中创建了你应该避免的 antipattern 非常重要。例如,我们见过一个常见错误:有人使用 pre-hook 在运行 model 之前 truncate destination table。但这并不必要,因为简单使用 table materialization 就会替你处理这种事情。在你构建的 dbt projects 中,应尽力避免这类场景。
本章最后,我们讨论了 run-operation command,它用于运行 macro。这个 command 可以 ad hoc 使用,也可以按某个 interval 调度运行。无论哪种方式,该 command 都提供了一种强大机制,用于运行与任何 dbt nodes 完全无关的代码。为了展示它如何工作,我们提供了两个示例:一个使用 Codegen package,另一个使用我们构建的 custom macro 来更新 development environment。
总体而言,hooks 和 operations 为你提供了一套强大的 toolset,用于运行那些不适合放进 dbt 其他 components 和 features 的代码。下一章中,我们将讨论 dbt 中我们最喜欢的 feature 之一:tests!