Chapter 18 · Capstone
Capstone: a Metadata-driven Dimension Loader
This chapter brings everything together. You'll turn the hand-written, 80-line dimemployee.sql into a model that declares its SCD2 rules in config and lets macros generate the rest. Along the way you'll use validated inputs, generated SQL, generic hooks, a generic test, a graph-walking audit, invocation logging and a safe rollout. The next Type 2 dimension then takes about fifteen lines.
The design
Requirements
- The generated SQL must be logically identical to today's
dimemployee, so existing keys and history are untouched. - A missing or wrong setting must fail with a clear message during execution, and must never break parsing.
- No hard-coded schema names, and no silent
run_iddefault. - Every SCD2 dimension must have the integrity test, enforced automatically.
Step 1: the metadata contract
dimension: scd2 # marks the model for the audit operation
surrogate_key: employeekey # filled from a sequence
business_key: employeenationalidalternatekey # identifies the entity
version_key: startdate # identifies the version
hash_column: row_hash # default row_hash
status_column: status # default status
current_value: Current # default Current
sequence: mart.dimemployee_key_seq # default <schema>.<table>_key_seq
parent_key: # optional self-reference
column: parentemployeekey
business_key: parentemployeenationalidalternatekey
Step 2: reading and validating it
{#- Returns the dwlab dimension settings of the calling model, with defaults applied.
Validates only while executing, so parsing never fails because of it. -#}
{% macro dim_meta() %}
{%- set raw = (config.get('meta') or {}).get('dwlab') or {} -%}
{%- set m = {
'dimension': raw.get('dimension'),
'surrogate_key': raw.get('surrogate_key'),
'business_key': raw.get('business_key'),
'version_key': raw.get('version_key'),
'hash_column': raw.get('hash_column', 'row_hash'),
'status_column': raw.get('status_column', 'status'),
'current_value': raw.get('current_value', 'Current'),
'sequence': raw.get('sequence', this.schema ~ '.' ~ this.identifier ~ '_key_seq'),
'parent_key': raw.get('parent_key')
} -%}
{%- if execute -%}
{%- for k in ['surrogate_key', 'business_key', 'version_key'] if not m[k] -%}
{{ exceptions.raise_compiler_error(model.name ~ ": meta.dwlab." ~ k ~ " is required") }}
{%- endfor -%}
{%- set expected_key = [m.business_key, m.version_key] -%}
{%- if config.get('unique_key') != expected_key -%}
{{ exceptions.raise_compiler_error(model.name ~ ": unique_key must be " ~ expected_key
~ " (got " ~ config.get('unique_key') ~ ")") }}
{%- endif -%}
{%- if m.parent_key and not (m.parent_key.get('column') and m.parent_key.get('business_key')) -%}
{{ exceptions.raise_compiler_error(model.name ~ ": parent_key needs 'column' and 'business_key'") }}
{%- endif -%}
{%- endif -%}
{{ return(m) }}
{% endmacro %}
{#- Column names of the prepared source, in order. Empty while parsing. -#}
{% macro dim_source_columns(relation) %}
{%- if not execute -%}{{ return([]) }}{%- endif -%}
{{ return(adapter.get_columns_in_relation(relation) | map(attribute='name') | list) }}
{% endmacro %}
You'll also need require_run_id() from chapter 17.
Step 3: generating the SELECT
{#- The full SELECT of an incremental SCD2 dimension (merge on business_key + version_key).
source: a Relation (ref/source) with one row per version, including the hash column. -#}
{% macro scd2_dimension(source) %}
{%- set m = dim_meta() -%}
{%- set rid = require_run_id() -%}
{%- set incr = is_incremental() -%}
{%- set cols = dim_source_columns(source) -%}
{%- set sk, bk, vk = m.surrogate_key, m.business_key, m.version_key -%}
{%- set st, cur = m.status_column, m.current_value -%}
with src as (
select * from {{ source }}
order by {{ bk }}, {{ vk }} -- keys are handed out in this order
)
{%- if incr %}
, tgt as (
select {{ sk }}, {{ bk }}, {{ vk }}, {{ m.hash_column }}, {{ st }},
dw_first_run_id, dw_expired_run_id, dw_inserted_at
from {{ this }}
)
{%- endif %}
select
{%- if incr %}
coalesce(t.{{ sk }}, nextval('{{ m.sequence }}')) as {{ sk }},
{%- else %}
nextval('{{ m.sequence }}') as {{ sk }},
{%- endif %}
{%- if m.parent_key %}
null::bigint as {{ m.parent_key.column }}, -- set by the post-hook
{%- endif %}
{%- for c in cols %}
s.{{ c }},
{%- endfor %}
-- audit columns
{%- if incr %}
coalesce(t.dw_first_run_id, {{ rid }}) as dw_first_run_id,
{{ rid }} as dw_last_run_id,
case when s.{{ st }} is null and t.{{ st }} = '{{ cur }}' then {{ rid }}
else t.dw_expired_run_id end as dw_expired_run_id,
coalesce(t.dw_inserted_at, now()) as dw_inserted_at,
{%- else %}
{{ rid }} as dw_first_run_id,
{{ rid }} as dw_last_run_id,
null::integer as dw_expired_run_id,
now() as dw_inserted_at,
{%- endif %}
now() as dw_updated_at
from src s
{%- if incr %}
left join tgt t
on t.{{ bk }} = s.{{ bk }}
and t.{{ vk }} = s.{{ vk }}
where t.{{ sk }} is null -- new version
or t.{{ m.hash_column }} is distinct from s.{{ m.hash_column }} -- changed version
{%- endif %}
{% endmacro %}
Why this parses safely
During parsing, dim_meta() doesn't validate, require_run_id() returns 0, is_incremental() is false and dim_source_columns() returns []. The macro produces a harmless SELECT with no source columns, which nobody runs. The ref() passed in as source was evaluated by the model, so the DAG edge is recorded. At run time, every piece has real values.
The column order is the same as today: the surrogate key, the parent key, the prepared columns in their view order (ending with row_hash), then the five audit columns. That's requirement 1.
Step 4: generic hooks
{% macro dim_pre_hook() %}
{%- set m = dim_meta() -%}
create sequence if not exists {{ m.sequence }}
{% endmacro %}
{% macro dim_post_hook() %}
{%- set m = dim_meta() -%}
{%- if m.parent_key -%}
update {{ this }} d
set {{ m.parent_key.column }} = p.{{ m.surrogate_key }}
from {{ this }} p
where p.{{ m.business_key }} = d.{{ m.parent_key.business_key }}
and p.{{ m.status_column }} = '{{ m.current_value }}'
and d.{{ m.parent_key.column }} is distinct from p.{{ m.surrogate_key }};
{%- endif %}
{{ record_dim_stats(scd2=true) }}
{% endmacro %}
record_dim_stats is the hardened version from chapter 17 (it uses source('audit', 'dim_load_stats') and require_run_id()). Both hook statements still run inside the merge's transaction, so they're atomic with the data (ch. 09).
Step 5: the new model
{#
mart.dimemployee — Type 2 on department periods, Type 1 on all other columns.
Everything below the config is generated by scd2_dimension(); see macros/dims/.
#}
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key=['employeenationalidalternatekey', 'startdate'],
on_schema_change='fail',
full_refresh=false,
meta={'dwlab': {
'dimension': 'scd2',
'surrogate_key': 'employeekey',
'business_key': 'employeenationalidalternatekey',
'version_key': 'startdate',
'parent_key': {'column': 'parentemployeekey',
'business_key': 'parentemployeenationalidalternatekey'}
}},
pre_hook="{{ dim_pre_hook() }}",
post_hook="{{ dim_post_hook() }}"
) }}
{{ scd2_dimension(ref('int_dimemployee_prepared')) }}
full_refresh=false is new
As chapter 05 showed, a full refresh would renumber every surrogate key. The capstone makes that impossible by accident. If you ever really need to rebuild, remove the flag deliberately in a reviewed change.
Step 6: tests
- name: dimemployee
description: "Employee dimension - Type 2 on DepartmentName, Type 1 on all other columns."
data_tests:
- scd2_integrity: # chapter 13 — replaces both assert_dimemployee_* files
arguments:
business_key: employeenationalidalternatekey
config:
store_failures: true
- dbt_utils.unique_combination_of_columns:
arguments:
combination_of_columns: [employeenationalidalternatekey, startdate]
columns:
- name: employeekey
data_tests: [unique, not_null]
# … existing column tests unchanged …
Step 7: enforce it across the project
{#- dbt run-operation audit_dimensions — fails if any meta.dwlab.dimension=scd2 model lacks scd2_integrity -#}
{% macro audit_dimensions() %}
{%- if not execute -%}{{ return('') }}{%- endif -%}
{%- set missing = [] -%}
{%- for n in graph.nodes.values()
if n.resource_type == 'model'
and (((n.config.meta or {}).get('dwlab') or {}).get('dimension')) == 'scd2' -%}
{%- set ns = namespace(tested=false) -%}
{%- for t in graph.nodes.values()
if t.resource_type == 'test'
and n.unique_id in t.depends_on.nodes
and (t.test_metadata or {}).get('name') == 'scd2_integrity' -%}
{%- set ns.tested = true -%}
{%- endfor -%}
{{ log(('✓ ' if ns.tested else '✗ ') ~ n.name, info=True) }}
{%- if not ns.tested -%}{%- do missing.append(n.name) -%}{%- endif -%}
{%- endfor -%}
{%- if missing -%}
{{ exceptions.raise_compiler_error("SCD2 dimensions without scd2_integrity: " ~ missing | join(', ')) }}
{%- endif -%}
{% endmacro %}
Add it as the first dbt task in the Airflow DAG, or in CI. It needs no database writes and no --vars.
Step 8: invocation logging
on-run-end:
- "{{ log_dbt_results(results) }}" # chapter 09
Step 9: roll out safely
- Save today's compiled SQL. Before changing anything, run
dbt compile -s dimemployee --vars '{"run_id": 999}'and copytarget/compiled/dwlab/models/mart/dimemployee.sqlto/tmp/before.sql. - Apply the change (the macros, the model and the YAML) and compile again with the same vars.
- Compare with
diff -w /tmp/before.sql target/compiled/…/dimemployee.sql. Only whitespace and comments should differ. Any other difference must be explained before you continue. - Check the hooks:
dbt --debug runagainst a copy of the database, then compare the logged hook SQL with the old hooks. - Run the tests:
dbt test -s dimemployeeanddbt run-operation audit_dimensions. - Delete the replaced singular tests once
scd2_integrityhas passed in the real pipeline.
Why compare compiled SQL?
The whole point of this refactor is that the text sent to PostgreSQL doesn't change. Comparing compiled SQL checks that directly and deterministically, without waiting for data to reveal a difference.
Step 10: the next dimension
Suppose a later pipeline prepares int_dimcustomer_prepared with one row per customer version and a row_hash. The whole dimension is then:
{{ config(
materialized='incremental', incremental_strategy='merge', on_schema_change='fail', full_refresh=false,
unique_key=['customeralternatekey', 'validfrom'],
meta={'dwlab': {'dimension': 'scd2', 'surrogate_key': 'customerkey',
'business_key': 'customeralternatekey', 'version_key': 'validfrom'}},
pre_hook="{{ dim_pre_hook() }}",
post_hook="{{ dim_post_hook() }}"
) }}
{{ scd2_dimension(ref('int_dimcustomer_prepared')) }}
If you forget the scd2_integrity test, audit_dimensions fails the pipeline.
Where to go from here
- Move the macros into
dwlab_macros(chapter 16) with an integration test project. - Switch the hash in
int_dimemployee_preparedto the dispatched, null-saferow_hash(chapters 08 and 10), with a planned one-time re-hash. - Make schemas environment-aware (chapter 11) so dev runs stop touching the lab's
mart. - Collapse stages 3–5 into one
dbt buildwith-- depends_on:hints onrpt_load_summary(internals ch. 05), and keeprecord_dbt_stepplus the new on-run-end log for auditing.
What you used
- Ch. 03–05: types,
namespace, config/meta,is_incremental,full_refresh - Ch. 06–09: macros returning data, generated SQL, introspection, hooks and transactions
- Ch. 13–14: generic tests and walking the graph
- Ch. 15–17: explicit errors, no hidden defaults or locations, a verifiable rollout
Exercises
1 · Composite business keys
Extend the contract so business_key may be a list. Which macros change?
dim_meta (normalise to a list and build expected_key as bk + [vk]), scd2_dimension (order by, the tgt select and the join, using scd2_key_join from chapter 06), the parent-key update in dim_post_hook, and scd2_integrity (partition by several columns).
2 · Prove nothing changed
Besides diffing compiled SQL, how can you prove after the first production run that no surrogate key changed?
Before the run, snapshot the keys (create table audit.dimemployee_keys_before as select employeenationalidalternatekey, startdate, employeekey from mart.dimemployee). After the run, join on the version key and check that no row has a different employeekey. audit_helper.compare_relations does the same in one macro call.