dbdocs 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbdocs/__init__.py +0 -0
- dbdocs/__main__.py +3 -0
- dbdocs/cli/__init__.py +0 -0
- dbdocs/cli/main.py +86 -0
- dbdocs/core/__init__.py +0 -0
- dbdocs/core/artifacts.py +82 -0
- dbdocs/core/config.py +117 -0
- dbdocs/core/exceptions.py +24 -0
- dbdocs/core/log.py +58 -0
- dbdocs/extract/__init__.py +0 -0
- dbdocs/extract/_sqlglot_lineage.py +267 -0
- dbdocs/extract/column_lineage.py +181 -0
- dbdocs/extract/erd.py +102 -0
- dbdocs/extract/erd_json.py +80 -0
- dbdocs/extract/graph.py +72 -0
- dbdocs/extract/nodes.py +119 -0
- dbdocs/main.py +6 -0
- dbdocs/site/__init__.py +0 -0
- dbdocs/site/builder.py +132 -0
- dbdocs/site/bundle/assets/app.js +500 -0
- dbdocs/site/bundle/assets/favicon.svg +12 -0
- dbdocs/site/bundle/assets/graph/index.css +1 -0
- dbdocs/site/bundle/assets/graph/index.js +62 -0
- dbdocs/site/bundle/assets/style.css +289 -0
- dbdocs/site/bundle/assets/vendor/marked.min.js +6 -0
- dbdocs/site/bundle/assets/vendor/minisearch.min.js +8 -0
- dbdocs/site/bundle/index.html +48 -0
- dbdocs/site/deploy.py +123 -0
- dbdocs/site/inject.py +32 -0
- dbdocs-0.0.0.dist-info/METADATA +78 -0
- dbdocs-0.0.0.dist-info/RECORD +34 -0
- dbdocs-0.0.0.dist-info/WHEEL +4 -0
- dbdocs-0.0.0.dist-info/entry_points.txt +2 -0
- dbdocs-0.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Column-level lineage: trace each model column back to its source columns.
|
|
2
|
+
|
|
3
|
+
For every model we parse its **compiled** SQL with sqlglot, qualify it against a
|
|
4
|
+
schema built from the dbt catalog (so ``SELECT *`` and unqualified columns
|
|
5
|
+
resolve), then walk each output column's lineage tree to its leaf table columns
|
|
6
|
+
and map those tables back to dbt unique_ids. The heavy lifting lives in the
|
|
7
|
+
vendored :mod:`dbdocs.extract._sqlglot_lineage` (a self-contained lineage
|
|
8
|
+
builder over sqlglot's optimizer).
|
|
9
|
+
|
|
10
|
+
Design: **fail-soft per model.** A single model with SQL sqlglot can't parse
|
|
11
|
+
must never sink the whole ``generate`` — it's caught, logged, and skipped, and
|
|
12
|
+
the run reports how many were skipped.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from sqlglot import exp
|
|
18
|
+
from sqlglot.errors import SqlglotError
|
|
19
|
+
|
|
20
|
+
from dbdocs.core.artifacts import db_schema, node_name
|
|
21
|
+
from dbdocs.core.exceptions import LineageError
|
|
22
|
+
from dbdocs.core.log import logger
|
|
23
|
+
from dbdocs.extract._sqlglot_lineage import Node, lineage
|
|
24
|
+
|
|
25
|
+
#: dbt adapter_type → sqlglot dialect, when the names differ. Most match 1:1.
|
|
26
|
+
_DIALECT_ALIASES = {
|
|
27
|
+
"databricks": "spark",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_dialect(adapter_type: "str | None") -> "str | None":
|
|
32
|
+
if not adapter_type:
|
|
33
|
+
return None
|
|
34
|
+
return _DIALECT_ALIASES.get(adapter_type, adapter_type)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ColumnLineageExtractor:
|
|
38
|
+
"""Build the ``columnLineage`` map for a dbt project's models.
|
|
39
|
+
|
|
40
|
+
The output maps a fully-qualified output column to the upstream columns it is
|
|
41
|
+
derived from::
|
|
42
|
+
|
|
43
|
+
{"model.shop.customers.customer_id": [{"node": "model.shop.stg_customers",
|
|
44
|
+
"column": "customer_id"}, ...]}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, manifest: Any, catalog: Any, dialect: "str | None" = None) -> None:
|
|
48
|
+
self.manifest = manifest
|
|
49
|
+
self.catalog = catalog
|
|
50
|
+
self.dialect = _to_dialect(dialect)
|
|
51
|
+
self.schema = self._schema_from_catalog()
|
|
52
|
+
# Map a lower-cased ``db.schema.table`` relation back to its unique_id.
|
|
53
|
+
self._relation_to_node = self._relation_index()
|
|
54
|
+
self.skipped = 0
|
|
55
|
+
|
|
56
|
+
def extract(self) -> dict:
|
|
57
|
+
"""Return the ``columnLineage`` map across all models (fail-soft)."""
|
|
58
|
+
result: dict = {}
|
|
59
|
+
for unique_id, model in (getattr(self.manifest, "nodes", {}) or {}).items():
|
|
60
|
+
if not str(unique_id).startswith("model."):
|
|
61
|
+
continue
|
|
62
|
+
compiled = getattr(model, "compiled_code", "") or ""
|
|
63
|
+
if not compiled.strip():
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
self._extract_model(unique_id, compiled, result)
|
|
67
|
+
except (SqlglotError, LineageError, KeyError, ValueError, RecursionError) as exc:
|
|
68
|
+
self.skipped += 1
|
|
69
|
+
logger.warning("Column lineage skipped for %s: %s", node_name(unique_id), exc)
|
|
70
|
+
if self.skipped:
|
|
71
|
+
logger.info("Column lineage: skipped %s model(s) that failed to parse.", self.skipped)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
def _extract_model(self, unique_id: str, compiled: str, result: dict) -> None:
|
|
75
|
+
node = self.manifest.nodes[unique_id]
|
|
76
|
+
output_columns = [c for c in (getattr(node, "columns", {}) or {})]
|
|
77
|
+
# Fall back to the catalog's column list when the manifest has none.
|
|
78
|
+
if not output_columns:
|
|
79
|
+
catalog_node = (getattr(self.catalog, "nodes", {}) or {}).get(unique_id)
|
|
80
|
+
output_columns = (
|
|
81
|
+
list(getattr(catalog_node, "columns", {}) or {}) if catalog_node else []
|
|
82
|
+
)
|
|
83
|
+
for column in output_columns:
|
|
84
|
+
try:
|
|
85
|
+
root = lineage(column, compiled, schema=self.schema, dialect=self.dialect)
|
|
86
|
+
except SqlglotError:
|
|
87
|
+
# One unresolvable column shouldn't drop the rest of the model.
|
|
88
|
+
continue
|
|
89
|
+
upstream = self._leaf_columns(root)
|
|
90
|
+
if upstream:
|
|
91
|
+
result[f"{unique_id}.{column}"] = upstream
|
|
92
|
+
|
|
93
|
+
def _leaf_columns(self, root: Node) -> list:
|
|
94
|
+
"""Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
|
|
95
|
+
|
|
96
|
+
A leaf is a node whose source is a real ``Table`` (not a CTE/subquery
|
|
97
|
+
scope) that we can map back to a dbt node. The root itself is skipped.
|
|
98
|
+
"""
|
|
99
|
+
seen = set()
|
|
100
|
+
upstream = []
|
|
101
|
+
for node in root.walk():
|
|
102
|
+
if node is root:
|
|
103
|
+
continue
|
|
104
|
+
source = node.source
|
|
105
|
+
if not isinstance(source, exp.Table):
|
|
106
|
+
continue
|
|
107
|
+
mapped = self._map_table(source)
|
|
108
|
+
if mapped is None:
|
|
109
|
+
continue
|
|
110
|
+
column = node_name(node.name)
|
|
111
|
+
key = (mapped, column)
|
|
112
|
+
if key in seen:
|
|
113
|
+
continue
|
|
114
|
+
seen.add(key)
|
|
115
|
+
upstream.append({"node": mapped, "column": column})
|
|
116
|
+
return upstream
|
|
117
|
+
|
|
118
|
+
def _map_table(self, table: exp.Table) -> "str | None":
|
|
119
|
+
catalog = table.catalog
|
|
120
|
+
db = table.db
|
|
121
|
+
name = table.name
|
|
122
|
+
candidates = [
|
|
123
|
+
f"{catalog}.{db}.{name}",
|
|
124
|
+
f"{db}.{name}",
|
|
125
|
+
name,
|
|
126
|
+
]
|
|
127
|
+
for candidate in candidates:
|
|
128
|
+
mapped = self._relation_to_node.get(candidate.lower().strip("."))
|
|
129
|
+
if mapped:
|
|
130
|
+
return mapped
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def _relation_index(self) -> dict:
|
|
134
|
+
"""Map ``db.schema.table`` (and shorter forms) → dbt unique_id."""
|
|
135
|
+
index: dict = {}
|
|
136
|
+
for unique_id, entity in self._all_entities():
|
|
137
|
+
database, schema = db_schema(entity)
|
|
138
|
+
table = getattr(entity, "alias", None) or getattr(entity, "name", None)
|
|
139
|
+
if not table:
|
|
140
|
+
continue
|
|
141
|
+
full = f"{database}.{schema}.{table}".lower()
|
|
142
|
+
index[full] = unique_id
|
|
143
|
+
index.setdefault(f"{schema}.{table}".lower(), unique_id)
|
|
144
|
+
index.setdefault(str(table).lower(), unique_id)
|
|
145
|
+
relation = getattr(entity, "relation_name", None)
|
|
146
|
+
if relation:
|
|
147
|
+
index.setdefault(str(relation).replace('"', "").lower(), unique_id)
|
|
148
|
+
return index
|
|
149
|
+
|
|
150
|
+
def _schema_from_catalog(self) -> dict:
|
|
151
|
+
"""Build sqlglot's nested ``{db: {schema: {table: {col: type}}}}`` schema."""
|
|
152
|
+
schema: dict = {}
|
|
153
|
+
for _, entity, columns in self._catalog_entities():
|
|
154
|
+
database, db_schema_name = db_schema(entity)
|
|
155
|
+
table = getattr(entity, "alias", None) or getattr(entity, "name", None)
|
|
156
|
+
if not table:
|
|
157
|
+
continue
|
|
158
|
+
col_types = {name: (col.type or "UNKNOWN") for name, col in columns.items()}
|
|
159
|
+
schema.setdefault(database, {}).setdefault(db_schema_name, {})[table] = col_types
|
|
160
|
+
return schema
|
|
161
|
+
|
|
162
|
+
def _all_entities(self):
|
|
163
|
+
yield from (getattr(self.manifest, "nodes", {}) or {}).items()
|
|
164
|
+
yield from (getattr(self.manifest, "sources", {}) or {}).items()
|
|
165
|
+
|
|
166
|
+
def _catalog_entities(self):
|
|
167
|
+
"""Yield ``(unique_id, manifest_entity, catalog_columns)`` for schema build.
|
|
168
|
+
|
|
169
|
+
Pairs the catalog's column list (types) with the manifest entity (the
|
|
170
|
+
authoritative database/schema, read via ``schema_``).
|
|
171
|
+
"""
|
|
172
|
+
manifest_nodes = getattr(self.manifest, "nodes", {}) or {}
|
|
173
|
+
manifest_sources = getattr(self.manifest, "sources", {}) or {}
|
|
174
|
+
for unique_id, catalog_node in (getattr(self.catalog, "nodes", {}) or {}).items():
|
|
175
|
+
entity = manifest_nodes.get(unique_id)
|
|
176
|
+
if entity is not None:
|
|
177
|
+
yield unique_id, entity, getattr(catalog_node, "columns", {}) or {}
|
|
178
|
+
for unique_id, catalog_source in (getattr(self.catalog, "sources", {}) or {}).items():
|
|
179
|
+
entity = manifest_sources.get(unique_id)
|
|
180
|
+
if entity is not None:
|
|
181
|
+
yield unique_id, entity, getattr(catalog_source, "columns", {}) or {}
|
dbdocs/extract/erd.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Structured ERD data via dbterd's ``json`` target.
|
|
2
|
+
|
|
3
|
+
dbterd's built-in targets emit diagram text; the SPA renders its ERD with React
|
|
4
|
+
Flow, which needs structured node/edge data. We register a ``json`` target
|
|
5
|
+
(:mod:`dbdocs.extract.erd_json`) and parse its ``{tables, relationships}`` output
|
|
6
|
+
into the SPA's ``{nodes, edges}`` — entities with columns (PK/FK flags) and
|
|
7
|
+
foreign-key edges between them, all keyed by dbt unique_id.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from dbterd.api import DbtErd
|
|
13
|
+
|
|
14
|
+
# Importing the module registers the "json" target with dbterd's PluginRegistry.
|
|
15
|
+
from dbdocs.extract import erd_json # noqa: F401
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_erd(dbterd_options: "dict | None" = None, artifacts_dir: "str | None" = None) -> DbtErd:
|
|
19
|
+
"""Build the ERD generator (json target) from dbdocs' ``dbterd`` options.
|
|
20
|
+
|
|
21
|
+
``dbterd_options`` is the ``dbterd:`` block of ``dbdocs.yml`` (``algo``,
|
|
22
|
+
``entity_name_format``, ``resource_type``, ``select``, …) passed straight to
|
|
23
|
+
``DbtErd``. We force ``target="json"`` — the SPA needs structured data — but
|
|
24
|
+
let everything else come from the project's config so the ERD matches what
|
|
25
|
+
the team configured. (Config lives in ``dbdocs.yml``, not a separate
|
|
26
|
+
``.dbterd.yml``.)
|
|
27
|
+
|
|
28
|
+
``artifacts_dir`` is the dbt target dir (``config.target_dir``). dbterd reads
|
|
29
|
+
the manifest/catalog directly from this dir; without it dbterd would default
|
|
30
|
+
to ``./target`` and ignore the configured ``target_dir``. An explicit
|
|
31
|
+
``artifacts_dir`` in ``dbterd_options`` still wins.
|
|
32
|
+
"""
|
|
33
|
+
dbterd_kwargs = {k: v for k, v in (dbterd_options or {}).items() if k != "target"}
|
|
34
|
+
if artifacts_dir is not None:
|
|
35
|
+
dbterd_kwargs.setdefault("artifacts_dir", artifacts_dir)
|
|
36
|
+
return DbtErd(target="json", **dbterd_kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_erd_data(erd: DbtErd) -> dict:
|
|
40
|
+
"""Parse the json target into ``{"nodes": [...], "edges": [...]}``.
|
|
41
|
+
|
|
42
|
+
Nodes are entities (with columns, ``is_primary_key``/``is_foreign_key`` flags
|
|
43
|
+
and the resolved dbt unique_id); edges are foreign-key relationships between
|
|
44
|
+
them. dbterd's relationships reference tables by *name*, so we map those back
|
|
45
|
+
to unique_ids via each table's ``node_name``.
|
|
46
|
+
"""
|
|
47
|
+
payload = json.loads(erd.get_erd())
|
|
48
|
+
tables = payload.get("tables", [])
|
|
49
|
+
relationships = payload.get("relationships", [])
|
|
50
|
+
|
|
51
|
+
# table name (as dbterd refers to it in relationships) → dbt unique_id.
|
|
52
|
+
name_to_id = {t["name"]: (t.get("node_name") or t["name"]) for t in tables}
|
|
53
|
+
|
|
54
|
+
edges, fk_columns = _build_edges(relationships, name_to_id)
|
|
55
|
+
nodes = [_build_node(t, fk_columns.get(t.get("node_name") or t["name"], set())) for t in tables]
|
|
56
|
+
return {"nodes": nodes, "edges": edges}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_edges(relationships: list, name_to_id: dict) -> "tuple[list, dict]":
|
|
60
|
+
"""Map relationships → edges and collect each node's FK column names."""
|
|
61
|
+
edges = []
|
|
62
|
+
fk_columns: dict = {}
|
|
63
|
+
for index, rel in enumerate(relationships):
|
|
64
|
+
parent_name, child_name = rel["table_map"]
|
|
65
|
+
parent_cols, child_cols = rel["column_map"]
|
|
66
|
+
source = name_to_id.get(parent_name, parent_name)
|
|
67
|
+
target = name_to_id.get(child_name, child_name)
|
|
68
|
+
# The child side holds the foreign key columns.
|
|
69
|
+
fk_columns.setdefault(target, set()).update(child_cols)
|
|
70
|
+
edges.append(
|
|
71
|
+
{
|
|
72
|
+
"id": rel.get("name") or f"e{index}",
|
|
73
|
+
"source": source,
|
|
74
|
+
"target": target,
|
|
75
|
+
"from_columns": list(parent_cols),
|
|
76
|
+
"to_columns": list(child_cols),
|
|
77
|
+
"label": rel.get("relationship_label"),
|
|
78
|
+
"type": rel.get("type", ""),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
return edges, fk_columns
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_node(table: dict, fk_cols: set) -> dict:
|
|
85
|
+
node_id = table.get("node_name") or table["name"]
|
|
86
|
+
return {
|
|
87
|
+
"id": node_id,
|
|
88
|
+
"label": table["name"],
|
|
89
|
+
"database": table.get("database") or "",
|
|
90
|
+
"schema": table.get("schema") or "",
|
|
91
|
+
"resource_type": table.get("resource_type") or "model",
|
|
92
|
+
"columns": [
|
|
93
|
+
{
|
|
94
|
+
"name": c["name"],
|
|
95
|
+
"type": c.get("data_type") or "",
|
|
96
|
+
"description": c.get("description") or "",
|
|
97
|
+
"is_primary_key": bool(c.get("is_primary_key")),
|
|
98
|
+
"is_foreign_key": c["name"] in fk_cols,
|
|
99
|
+
}
|
|
100
|
+
for c in table.get("columns", [])
|
|
101
|
+
],
|
|
102
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""A dbterd ``json`` target adapter: structured tables + relationships.
|
|
2
|
+
|
|
3
|
+
dbterd's built-in targets emit diagram *text* (Mermaid, DBML, …). The dbdocs SPA
|
|
4
|
+
renders its ERD with React Flow, which needs structured node/edge data, not a
|
|
5
|
+
diagram string. Registering this adapter makes ``DbtErd(target="json").get_erd()``
|
|
6
|
+
return a JSON document of ``{tables, relationships}`` that ``erd.build_erd_data``
|
|
7
|
+
turns into the SPA's ``{nodes, edges}``.
|
|
8
|
+
|
|
9
|
+
The ``Table``/``Column``/``Ref`` → dict serializers are pure and tested in
|
|
10
|
+
isolation; the adapter is the thin dbterd-contract shell over them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from dbterd.core.adapters.target import BaseTargetAdapter
|
|
17
|
+
from dbterd.core.models import Column, Ref, Table
|
|
18
|
+
from dbterd.core.registry.decorators import register_target
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def column_to_dict(column: Column) -> dict:
|
|
22
|
+
"""A dbterd ``Column`` as a plain dict (name, type, description, PK flag)."""
|
|
23
|
+
return {
|
|
24
|
+
"name": column.name,
|
|
25
|
+
"data_type": column.data_type,
|
|
26
|
+
"description": column.description,
|
|
27
|
+
"is_primary_key": bool(getattr(column, "is_primary_key", False)),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def table_to_dict(table: Table) -> dict:
|
|
32
|
+
"""A dbterd ``Table`` as a plain dict, keyed for ERD rendering."""
|
|
33
|
+
return {
|
|
34
|
+
"name": table.name,
|
|
35
|
+
"database": table.database,
|
|
36
|
+
"schema": table.schema,
|
|
37
|
+
"resource_type": table.resource_type,
|
|
38
|
+
"node_name": table.node_name,
|
|
39
|
+
"raw_sql": table.raw_sql,
|
|
40
|
+
"description": table.description,
|
|
41
|
+
"label": table.label,
|
|
42
|
+
"columns": [column_to_dict(c) for c in (table.columns or [])],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def relationship_to_dict(ref: Ref) -> dict:
|
|
47
|
+
"""A dbterd ``Ref`` as a plain dict: endpoints + the joined columns."""
|
|
48
|
+
parent, child = ref.table_map
|
|
49
|
+
parent_cols, child_cols = ref.column_map
|
|
50
|
+
return {
|
|
51
|
+
"name": ref.name,
|
|
52
|
+
"type": ref.type,
|
|
53
|
+
"table_map": [parent, child],
|
|
54
|
+
"column_map": [list(parent_cols), list(child_cols)],
|
|
55
|
+
"relationship_label": getattr(ref, "relationship_label", None),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register_target("json", description="Structured JSON of tables and relationships")
|
|
60
|
+
class JsonAdapter(BaseTargetAdapter):
|
|
61
|
+
"""Emit dbterd tables + relationships as one structured JSON document."""
|
|
62
|
+
|
|
63
|
+
file_extension = ".json"
|
|
64
|
+
default_filename = "output.json"
|
|
65
|
+
|
|
66
|
+
def build_erd(self, tables: list, relationships: list, **kwargs: Any) -> str:
|
|
67
|
+
payload = {
|
|
68
|
+
"tables": [table_to_dict(t) for t in tables],
|
|
69
|
+
"relationships": [relationship_to_dict(r) for r in relationships],
|
|
70
|
+
}
|
|
71
|
+
return json.dumps(payload)
|
|
72
|
+
|
|
73
|
+
def format_table(self, table: Table, **kwargs: Any) -> str:
|
|
74
|
+
return json.dumps(table_to_dict(table))
|
|
75
|
+
|
|
76
|
+
def format_relationship(self, relationship: Ref, **kwargs: Any) -> str:
|
|
77
|
+
return json.dumps(relationship_to_dict(relationship))
|
|
78
|
+
|
|
79
|
+
def get_rel_symbol(self, relationship_type: str) -> str:
|
|
80
|
+
return ""
|
dbdocs/extract/graph.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Node-level lineage (the DAG) from a dbt manifest.
|
|
2
|
+
|
|
3
|
+
``LineageGraph`` turns the manifest's ``parent_map`` (falling back to per-node
|
|
4
|
+
``depends_on.nodes``) into directed parent→child edges plus adjacency maps,
|
|
5
|
+
restricted to the nodes the SPA actually surfaces (models/seeds/snapshots/
|
|
6
|
+
sources) so test/macro dependencies don't dangle. The result feeds the
|
|
7
|
+
interactive DAG view.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dbdocs.core.artifacts import NODE_PREFIXES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LineageGraph:
|
|
16
|
+
"""The project lineage as ``{edges, parents, children}`` over surfaced nodes."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, manifest: Any, node_ids: "set | None" = None) -> None:
|
|
19
|
+
self.manifest = manifest
|
|
20
|
+
#: Restrict edges to these ids. Defaults to models/seeds/snapshots +
|
|
21
|
+
#: sources derived from the manifest, matching ``nodes.build_nodes``.
|
|
22
|
+
self.node_ids = node_ids if node_ids is not None else self._default_node_ids()
|
|
23
|
+
|
|
24
|
+
def _default_node_ids(self) -> set:
|
|
25
|
+
ids = {
|
|
26
|
+
uid
|
|
27
|
+
for uid in (getattr(self.manifest, "nodes", {}) or {})
|
|
28
|
+
if str(uid).startswith(NODE_PREFIXES)
|
|
29
|
+
}
|
|
30
|
+
ids.update(getattr(self.manifest, "sources", {}) or {})
|
|
31
|
+
return ids
|
|
32
|
+
|
|
33
|
+
def build(self) -> dict:
|
|
34
|
+
"""Return ``{"edges": [...], "parents": {...}, "children": {...}}``."""
|
|
35
|
+
edges = self._edges()
|
|
36
|
+
parents: dict = {n: [] for n in self.node_ids}
|
|
37
|
+
children: dict = {n: [] for n in self.node_ids}
|
|
38
|
+
for edge in edges:
|
|
39
|
+
parents[edge["target"]].append(edge["source"])
|
|
40
|
+
children[edge["source"]].append(edge["target"])
|
|
41
|
+
return {"edges": edges, "parents": parents, "children": children}
|
|
42
|
+
|
|
43
|
+
def _edges(self) -> list:
|
|
44
|
+
seen = set()
|
|
45
|
+
edges = []
|
|
46
|
+
for child, raw_parents in self._parent_pairs():
|
|
47
|
+
if child not in self.node_ids:
|
|
48
|
+
continue
|
|
49
|
+
for parent in raw_parents:
|
|
50
|
+
if parent not in self.node_ids:
|
|
51
|
+
continue
|
|
52
|
+
key = (parent, child)
|
|
53
|
+
if key in seen:
|
|
54
|
+
continue
|
|
55
|
+
seen.add(key)
|
|
56
|
+
edges.append({"source": parent, "target": child})
|
|
57
|
+
return edges
|
|
58
|
+
|
|
59
|
+
def _parent_pairs(self):
|
|
60
|
+
parent_map = getattr(self.manifest, "parent_map", None)
|
|
61
|
+
if parent_map:
|
|
62
|
+
yield from parent_map.items()
|
|
63
|
+
return
|
|
64
|
+
for unique_id in self.node_ids:
|
|
65
|
+
entity = self._lookup(unique_id)
|
|
66
|
+
depends_on = getattr(entity, "depends_on", None)
|
|
67
|
+
yield unique_id, list(getattr(depends_on, "nodes", []) or [])
|
|
68
|
+
|
|
69
|
+
def _lookup(self, unique_id: str) -> Any:
|
|
70
|
+
if str(unique_id).startswith("source."):
|
|
71
|
+
return (getattr(self.manifest, "sources", {}) or {}).get(unique_id)
|
|
72
|
+
return (getattr(self.manifest, "nodes", {}) or {}).get(unique_id)
|
dbdocs/extract/nodes.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Extract the SPA's ``nodes`` and ``tree`` data from dbt artifacts.
|
|
2
|
+
|
|
3
|
+
``build_nodes`` flattens every model/source/seed/snapshot into a display record
|
|
4
|
+
(columns merged from manifest descriptions + catalog types, transformation code,
|
|
5
|
+
resolved macros). ``build_tree`` groups those into the ``database → schema``
|
|
6
|
+
navigation tree. Pure functions — no I/O, no dbterd calls beyond reading the
|
|
7
|
+
already-parsed objects — so they're trivially testable with lightweight fakes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dbdocs.core.artifacts import NODE_PREFIXES, db_schema, node_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _columns(model: Any, catalog_node: Any) -> list:
|
|
16
|
+
"""Merge manifest column metadata (description/tags) with catalog types.
|
|
17
|
+
|
|
18
|
+
Iterates the catalog's columns (the warehouse truth for which columns exist
|
|
19
|
+
and their types) and layers on the manifest description/tags when present.
|
|
20
|
+
Newlines in descriptions become ``<br>`` so they survive HTML rendering.
|
|
21
|
+
"""
|
|
22
|
+
manifest_columns = getattr(model, "columns", {}) or {}
|
|
23
|
+
catalog_columns = getattr(catalog_node, "columns", {}) or {} if catalog_node else {}
|
|
24
|
+
columns = []
|
|
25
|
+
for name in catalog_columns:
|
|
26
|
+
manifest_column = manifest_columns.get(name)
|
|
27
|
+
description = getattr(manifest_column, "description", "") or "" if manifest_column else ""
|
|
28
|
+
columns.append(
|
|
29
|
+
{
|
|
30
|
+
"name": name,
|
|
31
|
+
"type": catalog_columns[name].type,
|
|
32
|
+
"tags": (getattr(manifest_column, "tags", []) or []) if manifest_column else [],
|
|
33
|
+
"description": description.replace("\n", "<br>"),
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
return columns
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def macros_used(manifest: Any, node: Any) -> list:
|
|
40
|
+
"""The macros a node depends on, resolved to ``{name, package, sql}`` dicts.
|
|
41
|
+
|
|
42
|
+
``depends_on.macros`` holds macro unique_ids; each is looked up in
|
|
43
|
+
``manifest.macros``. Project macros come first (what a reader most wants),
|
|
44
|
+
then everything else, each group name-sorted.
|
|
45
|
+
"""
|
|
46
|
+
macros = getattr(manifest, "macros", {}) or {}
|
|
47
|
+
depends_on = getattr(node, "depends_on", None)
|
|
48
|
+
macro_ids = list(getattr(depends_on, "macros", []) or [])
|
|
49
|
+
resolved = []
|
|
50
|
+
for macro_id in macro_ids:
|
|
51
|
+
macro = macros.get(macro_id)
|
|
52
|
+
if macro is None:
|
|
53
|
+
continue
|
|
54
|
+
resolved.append(
|
|
55
|
+
{
|
|
56
|
+
"name": getattr(macro, "name", node_name(macro_id)),
|
|
57
|
+
"package": getattr(macro, "package_name", "") or "",
|
|
58
|
+
"sql": getattr(macro, "macro_sql", "") or "",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
project_pkg = getattr(node, "package_name", "") or ""
|
|
62
|
+
resolved.sort(key=lambda m: (m["package"] != project_pkg, m["package"], m["name"]))
|
|
63
|
+
return resolved
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _node_record(unique_id: str, entity: Any, catalog_node: Any, resource_type: str, manifest: Any):
|
|
67
|
+
database, schema = db_schema(entity)
|
|
68
|
+
return {
|
|
69
|
+
"id": unique_id,
|
|
70
|
+
"name": getattr(entity, "name", node_name(unique_id)),
|
|
71
|
+
"label": node_name(unique_id),
|
|
72
|
+
"resource_type": resource_type,
|
|
73
|
+
"database": database,
|
|
74
|
+
"schema": schema,
|
|
75
|
+
"package": getattr(entity, "package_name", "") or "",
|
|
76
|
+
"description": getattr(entity, "description", "") or "",
|
|
77
|
+
"tags": list(getattr(entity, "tags", []) or []),
|
|
78
|
+
"relation_name": getattr(entity, "relation_name", "") or "",
|
|
79
|
+
"columns": _columns(entity, catalog_node),
|
|
80
|
+
"language": getattr(entity, "language", "") or "",
|
|
81
|
+
"raw_code": getattr(entity, "raw_code", "") or "",
|
|
82
|
+
"compiled_code": getattr(entity, "compiled_code", "") or "",
|
|
83
|
+
"macros": macros_used(manifest, entity),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_nodes(manifest: Any, catalog: Any) -> dict:
|
|
88
|
+
"""Return the ``nodes`` dict keyed by unique_id (models + sources)."""
|
|
89
|
+
catalog_nodes = getattr(catalog, "nodes", {}) or {}
|
|
90
|
+
catalog_sources = getattr(catalog, "sources", {}) or {}
|
|
91
|
+
nodes: dict = {}
|
|
92
|
+
for unique_id, entity in (getattr(manifest, "nodes", {}) or {}).items():
|
|
93
|
+
if not str(unique_id).startswith(NODE_PREFIXES):
|
|
94
|
+
continue
|
|
95
|
+
resource_type = str(unique_id).split(".")[0]
|
|
96
|
+
nodes[unique_id] = _node_record(
|
|
97
|
+
unique_id, entity, catalog_nodes.get(unique_id), resource_type, manifest
|
|
98
|
+
)
|
|
99
|
+
for unique_id, entity in (getattr(manifest, "sources", {}) or {}).items():
|
|
100
|
+
nodes[unique_id] = _node_record(
|
|
101
|
+
unique_id, entity, catalog_sources.get(unique_id), "source", manifest
|
|
102
|
+
)
|
|
103
|
+
return nodes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_tree(nodes: dict) -> dict:
|
|
107
|
+
"""Group node ids into an ordered ``{database: {schema: [ids]}}`` nav tree."""
|
|
108
|
+
by_database: dict = {}
|
|
109
|
+
for unique_id, record in nodes.items():
|
|
110
|
+
database = record["database"]
|
|
111
|
+
schema = record["schema"]
|
|
112
|
+
by_database.setdefault(database, {}).setdefault(schema, []).append(unique_id)
|
|
113
|
+
return {
|
|
114
|
+
database: {
|
|
115
|
+
schema: sorted(by_database[database][schema], key=lambda i: nodes[i]["label"])
|
|
116
|
+
for schema in sorted(by_database[database])
|
|
117
|
+
}
|
|
118
|
+
for database in sorted(by_database)
|
|
119
|
+
}
|
dbdocs/main.py
ADDED
dbdocs/site/__init__.py
ADDED
|
File without changes
|