Node anatomy: unique_id, paths, refs, depends_on, config, compiled_sql
В предыдущем уроке мы посмотрели manifest.json с верхнего уровня. Теперь — вглубь. Node (model, test, snapshot, seed, analysis, operation) имеет десятки полей, каждое со специфической семантикой. Senior должен знать их наизусть, потому что любое tooling-усилие (observability, optimization, custom integrations) сводится к чтению этих полей.
Цель урока — выйти от node['refs'] к полному mental model: чем path отличается от original_file_path, почему unique_id имеет три сегмента, что хранится в depends_on vs refs, когда compiled_code равно null, что такое checksum и зачем.
Defining contract: columns, data_type, constraints (dbt II) Exposures: декларация downstream-консьюмеров (dbt I)
Identity полей
unique_id
"unique_id": "model.jaffle_shop.customers"
Формат: <resource_type>.<package>.<name> (для some — добавляется .<version> или другие сегменты).
Конкретные примеры:
model.jaffle_shop.customers— modelcustomersв проектеjaffle_shopmodel.jaffle_shop.fct_orders.v2— versioned modelsource.jaffle_shop.raw.orders— sourceordersв grouprawв проектеjaffle_shopseed.jaffle_shop.country_codes— seedtest.jaffle_shop.not_null_customers_id.abc123— auto-generated test namedata_test.jaffle_shop.my_custom_test— generic testsnapshot.jaffle_shop.user_history— snapshotanalysis.jaffle_shop.revenue_overview— analysisoperation.jaffle_shop.create_external_schema— on-run hookmacro.dbt_utils.surrogate_key— macro из dbt_utils packageexposure.jaffle_shop.executive_dashboard— exposuremetric.jaffle_shop.revenue— metric (legacy semantic layer)semantic_model.jaffle_shop.orders— semantic model (MetricFlow)saved_query.jaffle_shop.weekly_revenue— MetricFlow saved querygroup.jaffle_shop.finance— model groupunit_test.jaffle_shop.test_revenue_logic— unit testdoc.jaffle_shop.customer_id— doc block
unique_id — единственный идентификатор, который никогда не меняется между runs (если файл не переименован). Используйте его как primary key в observability databases, кеш-ключах, lineage records.
name
"name": "customers"
Простое имя файла (без .sql). Может конфликтовать между packages — поэтому unique_id includes package.
fqn (Fully Qualified Name)
"fqn": ["jaffle_shop", "marts", "customers"]
Массив, отражающий иерархию: [<package>, <folder1>, <folder2>, ..., <name>]. Это используется для selection syntax:
dbt run --select marts # все models в фолдере marts/
dbt run --select marts.customers # одну модель
dbt run --select +marts # +parents
fqn также определяет config inheritance из dbt_project.yml:
models:
jaffle_shop:
marts:
+materialized: table # apply ко всем marts
Этот config matches fqn = ['jaffle_shop', 'marts', '*'].
resource_type
"resource_type": "model"
Один из: model, test, data_test, unit_test, snapshot, seed, analysis, operation, sql_operation, source, macro, exposure, metric, semantic_model, saved_query, group, doc.
package_name
"package_name": "jaffle_shop"
Из какого пакета (root project, dbt_utils, audit_helper, custom).
Path полей
dbt различает три path:
{
"path": "marts/customers.sql",
"original_file_path": "models/marts/customers.sql",
"root_path": "/Users/alice/jaffle_shop"
}
original_file_path— relative path от project root (включаетmodels/,tests/,macros/, и т.д.). Это что вы видите в file system.path— relative path внутри resource type фолдера (безmodels/префикса). Используется dbt для compile output.root_path— абсолютный путь project root. Removed в новых versions (mesh-incompatible). Используйтеoriginal_file_path.
В dbt 1.6+ root_path начал убираться (mesh projects могут жить в разных директориях). Production tools должны использовать original_file_path. Если совмещаете с file system, joinpath относительно known dbt project root.
Where compiled output goes
"compiled_path": "target/compiled/jaffle_shop/models/marts/customers.sql"
dbt записывает rendered SQL в target/compiled/<package>/<original_file_path> после dbt compile или dbt run. Это mirror structure repo внутри target/. Useful для debugging: сравнить compiled SQL с raw.
Также:
"build_path": "target/run/jaffle_shop/models/marts/customers.sql"
target/run/ содержит final DDL (CREATE TABLE AS, MERGE, etc.), который dbt отправил adapter. Это compiled SQL обёрнутый в materialization template.
Database/schema/alias
{
"database": "jaffle_shop",
"schema": "main",
"alias": "customers"
}
- database — куда писать (resolved через
generate_database_name). - schema — куда писать (resolved через
generate_schema_name). - alias — final table/view name (по умолчанию =
name, но можно override черезconfig: {alias: 'foo'}).
Final FQN в warehouse: {database}.{schema}.{alias}.
В dev: jaffle_shop_dev.dbt_alice_dev_marts.customers (если generate_schema_name добавляет user prefix).
Refs и Sources
refs
{
"refs": [
{"name": "stg_customers", "package": null, "version": null},
{"name": "stg_orders", "package": "ext_jaffle_shop", "version": "v2"}
]
}
Список того, что модель референсит через {{ ref('...') }}. Каждый ref — dictionary:
name— имя target nodepackage— package, если cross-project (null = same project)version— версия (для versioned models, добавлено в schema v9)
До schema v9 refs были просто strings: ["stg_customers", "stg_orders"]. Versioned models потребовали переход на dictionaries. Tools должны handle оба format при поддержке нескольких dbt versions.
sources
{
"sources": [
["raw", "orders"],
["raw", "customers"]
]
}
Список того, что модель референсит через source. Каждый source — список [source_name, table_name].
Чтобы получить full source unique_id:
for src in node['sources']:
source_name, table_name = src
package = node['package_name']
source_uid = f"source.{package}.{source_name}.{table_name}"
metrics
{
"metrics": [
["revenue"],
["arr"]
]
}
То же для {{ metric('name') }} calls.
depends_on — flattened upstream
{
"depends_on": {
"nodes": [
"model.jaffle_shop.stg_customers",
"model.jaffle_shop.stg_orders",
"source.jaffle_shop.raw.region"
],
"macros": [
"macro.dbt.statement",
"macro.dbt_utils.surrogate_key"
]
}
}
depends_on — resolved unique_ids всех upstream (nodes + macros). Это то, что Linker вычислил, обходя refs/sources/metrics. Используется для:
- Топологическая сортировка для run order
- Selection:
+model= walk parents через depends_on.nodes - Macro dispatch — depends_on.macros показывает, что использовала node
config — материализация и метаданные
{
"config": {
"enabled": true,
"materialized": "incremental",
"unique_key": "order_id",
"incremental_strategy": "merge",
"merge_exclude_columns": ["created_at"],
"on_schema_change": "append_new_columns",
"schema": null,
"database": null,
"alias": null,
"tags": ["nightly", "marts"],
"meta": {
"owner": "analytics",
"pii": false,
"cost_per_run": 2.50
},
"grants": {
"select": ["analyst_role"]
},
"persist_docs": {
"relation": true,
"columns": true
},
"contract": {
"enforced": false
},
"access": "protected",
"group": null,
"docs": {"show": true, "node_color": "#FF9900"},
"pre_hook": [],
"post_hook": [],
"full_refresh": null,
"snapshot_meta_column_names": {}
}
}
Самый rich object. Содержит resolved config (after merging dbt_project.yml defaults, model-level config, YAML config, target overrides). Это post-processing representation.
Ключевые подразделы:
- Materialization:
materialized(table/view/incremental/ephemeral/materialized_view/snapshot/microbatch/custom). - Incremental:
unique_key,incremental_strategy,on_schema_change,merge_exclude_columns,merge_update_columns,microbatch_strategy,event_time,lookback,begin,batch_size. - Identity overrides:
schema,database,alias(если null — используются defaults). - Tagging:
tags— array строк. - Metadata:
meta— arbitrary dictionary для custom metadata (owners, PII flags, cost). - Permissions:
grants— adapter-specific. - Documentation:
persist_docs,docs.show,docs.node_color. - Governance:
contract.enforced,access(public/private/protected),group. - Hooks:
pre_hook,post_hook— arrays SQL statements. - Snapshots:
strategy,updated_at,check_cols,target_database,target_schema, и т.д. - Custom: любые user-defined keys через
+my_custom: ...вdbt_project.yml.
config — это resolved config для current target. Если у вас prod-specific config (+materialized: "{{ 'table' if target.name == 'prod' else 'view' }}"), то в manifest вы увидите 'table' для prod target, 'view' для dev. Не raw template.
columns — colum-level metadata
{
"columns": {
"order_id": {
"name": "order_id",
"description": "Order PK",
"data_type": "BIGINT",
"constraints": [
{"type": "not_null"},
{"type": "primary_key"}
],
"quote": null,
"tags": ["pk"],
"meta": {"pii": false},
"data_tests": []
},
"amount": {
"name": "amount",
"description": "Order total in USD",
"data_type": "NUMERIC(10,2)",
"constraints": [
{"type": "check", "expression": "amount >= 0"}
],
"quote": null,
"tags": [],
"meta": {}
}
}
}
Каждая column в schema.yml (или model.yml) попадает в manifest. Поля:
- name — column name
- description — для docs site,
persist_docs.columns: true— also в warehouse - data_type — для model contracts (validates type matches warehouse)
- constraints — array constraints (not_null, primary_key, foreign_key, unique, check)
- quote — quote in queries (
true/false/null= default) - tags — column-level tags (для grants/data classification)
- meta — arbitrary custom metadata
Если column не в YAML — не будет в manifest. dbt-osmosis solves this by inferring через downstream model columns.
raw_code vs compiled_code
{
"raw_code": "SELECT customer_id, COUNT(*) AS order_count FROM {{ ref('stg_orders') }} GROUP BY 1",
"compiled_code": "SELECT customer_id, COUNT(*) AS order_count FROM \"jaffle_shop\".\"main\".\"stg_orders\" GROUP BY 1",
"compiled": true
}
- raw_code — оригинальный SQL/Python из файла. Always populated after parsing.
- compiled_code — rendered Jinja. Null после parsing, заполняется после
dbt compile/dbt run. - compiled — boolean flag.
Для Python models:
{
"raw_code": "def model(dbt, session): ...",
"language": "python"
}
language поле differentiate SQL vs Python.
Если ваш tool читает manifest до compile phase (например, для dbt parse-only workflows), не полагайтесь на compiled_code. Используйте raw_code или explicit re-compile через dbt Python API.
checksum
{
"checksum": {
"name": "sha256",
"checksum": "8f2a1c4e7b9d3f6a..."
}
}
SHA256 hash содержимого файла (raw_code + некоторые dependencies). Используется для:
- Partial parsing: dbt сравнивает checksum в
partial_parse.msgpackс current file hash. Если совпадает — не парсит заново. - state:modified:
dbt run --select state:modified+ --state previous_run/— отлично через checksum определяет, изменилась ли модель. - Slim CI: тот же mechanism для PR-vs-main diff.
checksum покрывает только содержимое модели, не её зависимости. Если изменился macro, который использует модель, checksum модели не изменится. dbt отслеживает это через state:modified.macros отдельно.
meta, tags, group, docs
meta
{"meta": {"owner": "analytics", "pii": false}}
Arbitrary key-value. Используется для:
- Owner tracking
- PII/PHI classification
- Cost attribution
- Custom workflows (например, “if meta.priority == ‘high’, alert on failure”)
Tools обычно итерируют nodes, фильтруют по meta.<key>.
tags
{"tags": ["nightly", "marts", "finance"]}
Список строк. Используется для:
dbt run --select tag:nightly- Grouping в docs site
- Workflow routing
group
{"group": "finance"}
Membership в model group (для access control). Public/private/protected models gated через group ownership.
docs
{"docs": {"show": true, "node_color": "#FF9900"}}
show— показывать ли в docs site (false скрывает legacy nodes)node_color— цвет в lineage graph
Tests-specific поля
Тесты (resource_type=‘test’ или ‘data_test’) имеют дополнительные поля:
{
"unique_id": "test.jaffle_shop.not_null_customers_id.abc12345",
"name": "not_null_customers_id",
"resource_type": "test",
"test_metadata": {
"name": "not_null",
"namespace": null,
"kwargs": {
"column_name": "id",
"model": "{{ get_where_subquery(ref('customers')) }}"
}
},
"column_name": "id",
"attached_node": "model.jaffle_shop.customers",
"config": {
"severity": "error",
"warn_if": "!= 0",
"error_if": "!= 0",
"store_failures": false,
"limit": null,
"where": null
}
}
Ключевые тестовые поля:
- test_metadata.name — имя generic test (not_null, unique, accepted_values, relationships, etc.)
- test_metadata.kwargs — параметры test
- attached_node — к какой модели/source attached (для column-level tests)
- config.severity —
errorилиwarn - config.store_failures — сохранять failed rows в warehouse
- config.limit — limit failed rows
Это позволяет tools (Elementary, dbt-checkpoint) понять, какие тесты к каким моделям относятся.
Snapshot-specific поля
{
"unique_id": "snapshot.jaffle_shop.user_history",
"resource_type": "snapshot",
"config": {
"strategy": "check",
"unique_key": "user_id",
"check_cols": ["email", "subscription_tier"],
"updated_at": null,
"target_database": "jaffle_shop",
"target_schema": "snapshots",
"snapshot_meta_column_names": {
"dbt_valid_from": "valid_from",
"dbt_valid_to": "valid_to",
"dbt_scd_id": "scd_id",
"dbt_updated_at": "updated_at"
},
"hard_deletes": "ignore"
}
}
Snapshots имеют свой config block — strategy, check_cols (для check strategy), updated_at (для timestamp strategy), и snapshot meta columns.
Seed-specific поля
{
"unique_id": "seed.jaffle_shop.country_codes",
"resource_type": "seed",
"path": "seeds/country_codes.csv",
"config": {
"materialized": "seed",
"delimiter": ",",
"quote_columns": null,
"column_types": {
"code": "VARCHAR(3)",
"country": "VARCHAR(100)"
}
},
"root_path": "...",
"package_name": "jaffle_shop"
}
Note: compiled_code для seed обычно null (CSV processing идёт separately).
Реальный пример — incremental model
{
"model.jaffle_shop.fct_orders": {
"database": "jaffle_shop",
"schema": "marts",
"name": "fct_orders",
"resource_type": "model",
"package_name": "jaffle_shop",
"path": "marts/fct_orders.sql",
"original_file_path": "models/marts/fct_orders.sql",
"unique_id": "model.jaffle_shop.fct_orders",
"fqn": ["jaffle_shop", "marts", "fct_orders"],
"alias": "fct_orders",
"checksum": {"name": "sha256", "checksum": "9a8f..."},
"language": "sql",
"config": {
"enabled": true,
"materialized": "incremental",
"unique_key": "order_id",
"incremental_strategy": "merge",
"on_schema_change": "append_new_columns",
"tags": ["finance", "nightly"],
"meta": {"owner": "data-team", "cost_attribution": "finance"},
"grants": {"select": ["analyst_role"]},
"persist_docs": {"relation": true, "columns": true},
"contract": {"enforced": true},
"access": "public",
"group": "finance"
},
"tags": ["finance", "nightly"],
"description": "Order facts — one row per order",
"columns": {
"order_id": {
"name": "order_id",
"description": "Order PK",
"data_type": "BIGINT",
"constraints": [{"type": "not_null"}, {"type": "primary_key"}]
},
"customer_id": {
"name": "customer_id",
"description": "FK to dim_customers",
"data_type": "BIGINT",
"constraints": [{"type": "not_null"}]
},
"amount_usd": {
"name": "amount_usd",
"description": "Order total in USD",
"data_type": "NUMERIC(10,2)",
"constraints": [{"type": "check", "expression": "amount_usd >= 0"}]
}
},
"meta": {"owner": "data-team"},
"group": "finance",
"refs": [
{"name": "stg_orders", "package": null, "version": null},
{"name": "stg_payments", "package": null, "version": null}
],
"sources": [["raw", "exchange_rates"]],
"metrics": [],
"depends_on": {
"macros": [
"macro.dbt.statement",
"macro.dbt.is_incremental",
"macro.jaffle_shop.convert_currency"
],
"nodes": [
"model.jaffle_shop.stg_orders",
"model.jaffle_shop.stg_payments",
"source.jaffle_shop.raw.exchange_rates"
]
},
"compiled_path": "target/compiled/jaffle_shop/models/marts/fct_orders.sql",
"build_path": "target/run/jaffle_shop/models/marts/fct_orders.sql",
"compiled": true,
"compiled_code": "MERGE INTO ... USING (...) ON ... WHEN MATCHED THEN UPDATE SET ... WHEN NOT MATCHED THEN INSERT ...",
"raw_code": "SELECT o.order_id, ... FROM {{ ref('stg_orders') }} o ...",
"extra_ctes_injected": true,
"extra_ctes": []
}
}
Это полный node после dbt run для production. Видно:
- Incremental с merge strategy
- Contract enforced (data_types + constraints)
- Public access, в group
finance - Используется метаданные: owner, cost_attribution, persist_docs, grants
- depends_on resolved через Linker
Python integration — pydantic models
dbt-core внутренне использует pydantic models для validation. Можно использовать их в tools:
from dbt.contracts.graph.nodes import ModelNode
from dbt.contracts.graph.manifest import Manifest
import json
manifest_data = json.load(open("target/manifest.json"))
manifest = Manifest.from_dict(manifest_data)
for unique_id, node in manifest.nodes.items():
if isinstance(node, ModelNode):
print(f"{node.unique_id}: {node.config.materialized}")
print(f" Refs: {[r.name for r in node.refs]}")
print(f" Depends on: {node.depends_on.nodes}")
print(f" Tags: {node.tags}")
if node.config.materialized == "incremental":
print(f" Strategy: {node.config.incremental_strategy}")
print(f" Unique key: {node.config.unique_key}")
Преимущество — type safety. Недостаток — dbt-core внутренний API меняется между versions. Production-tools используют либо raw JSON либо stable Python API.
В уроке 04-parsing-manifest-python.mdx мы подробнее разберём, как извлекать info из manifest, включая edge cases (None vs missing fields, schema migration, performance).
Антипаттерны при работе с nodes
1. Assumption refs are strings
# ПЛОХО: работает только для старых dbt versions
for ref in node['refs']:
upstream_name = ref
С schema v9 refs — dicts. Правильно:
for ref in node['refs']:
if isinstance(ref, dict):
upstream_name = ref['name']
elif isinstance(ref, str):
upstream_name = ref # legacy
elif isinstance(ref, list):
upstream_name = ref[0] # very old
2. Joining refs с current package
# ПЛОХО: ломается для cross-project refs
upstream_uid = f"model.{node['package_name']}.{ref['name']}"
Правильно — использовать ref['package']:
target_package = ref.get('package') or node['package_name']
upstream_uid = f"model.{target_package}.{ref['name']}"
3. Игнорирование sources в depends_on
# ПЛОХО: депенденси analytics tool пропускает source dependencies
parents = node['depends_on']['nodes']
sources = [p for p in parents if p.startswith('source.')]
# sources уже включены в depends_on.nodes!
depends_on.nodes включает models, sources, snapshots, seeds — всё. Не нужно их искать отдельно.
4. Парсинг compiled_code при parsing-only workflow
# ПЛОХО: пытаемся извлечь SELECT columns из compiled_code, но он null
import sqlparse
parsed = sqlparse.parse(node['compiled_code']) # error: NoneType
Если manifest из dbt parse (без compile), compiled_code = null. Проверяйте:
if node.get('compiled_code'):
parsed = sqlparse.parse(node['compiled_code'])
else:
# Fallback к raw_code или skip
pass
5. Hardcoded paths
# ПЛОХО: ломается на mesh / different OSes
abs_path = node['root_path'] + '/' + node['original_file_path']
Используйте pathlib:
from pathlib import Path
project_root = Path(os.environ['DBT_PROJECT_DIR'])
abs_path = project_root / node['original_file_path']
6. Использование name вместо unique_id
# ПЛОХО: name может совпадать в разных packages
nodes_by_name = {n['name']: n for n in manifest['nodes'].values()}
# overwrites при коллизии
Используйте unique_id как key.
Performance hints
1. Lazy loading
Огромный manifest — не загружайте всё:
import ijson
def find_incremental_models(manifest_path):
with open(manifest_path, 'rb') as f:
for prefix, event, value in ijson.parse(f):
if (prefix.endswith('.config.materialized')
and event == 'string'
and value == 'incremental'):
yield prefix.split('.')[1] # extract unique_id
2. Index по resource_type
def index_manifest(manifest):
by_type = defaultdict(dict)
for uid, node in manifest['nodes'].items():
by_type[node['resource_type']][uid] = node
return by_type
idx = index_manifest(manifest)
models = idx['model'] # O(1) lookup
tests = idx['test']
3. Pre-build child_map для downstream
Если parent_map уже есть, child_map тоже. Если нужно walk further:
def downstream(unique_id, child_map, max_depth=None):
visited = set()
queue = [(unique_id, 0)]
while queue:
node, depth = queue.pop(0)
if node in visited:
continue
visited.add(node)
if max_depth and depth >= max_depth:
continue
for child in child_map.get(node, []):
queue.append((child, depth + 1))
return visited - {unique_id}
Ключевые выводы
- unique_id формат —
<resource_type>.<package>.<name>(плюс version, hash для tests). Единственный stable identifier. - fqn — иерархия в form массива. Используется для selection и config inheritance.
- Three paths:
original_file_path(relative к project),path(relative к resource folder),root_path(absolute, deprecated в mesh). - refs: до v9 — strings, v9+ — dicts с name/package/version.
- depends_on — resolved upstream unique_ids (nodes + macros). Linker computes.
- config — post-merge resolved config (после dbt_project.yml + model + YAML + target overrides).
- columns — column-level metadata: name, description, data_type, constraints, tests.
- raw_code всегда есть после parsing; compiled_code только после compile/run.
- checksum — SHA256 raw content. Используется для partial parsing и state:modified.
- tests имеют test_metadata + attached_node поля; snapshots — strategy + check_cols/updated_at; seeds — delimiter + column_types.
- Python integration через
Manifest.from_dict()— type safety, но fragile API. - Performance: streaming для больших manifest, index по resource_type, careful с paths.