dbdocs 1.2.0__tar.gz → 1.4.0__tar.gz
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-1.2.0 → dbdocs-1.4.0}/.gitignore +2 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/PKG-INFO +13 -15
- dbdocs-1.4.0/README.md +67 -0
- dbdocs-1.4.0/dbdocs/extract/erd.py +150 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/dimensions.py +22 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/__init__.py +14 -1
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/base.py +17 -2
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/documentation.py +30 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/modeling.py +96 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/structure.py +18 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/testing.py +19 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/registry.py +14 -1
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/css/style.css +19 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/graph/index.js +7 -7
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/service.js +169 -1
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/ui.js +137 -17
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/index.html +3 -2
- {dbdocs-1.2.0 → dbdocs-1.4.0}/pyproject.toml +3 -2
- dbdocs-1.2.0/README.md +0 -69
- dbdocs-1.2.0/dbdocs/extract/erd.py +0 -111
- dbdocs-1.2.0/dbdocs/extract/erd_json.py +0 -80
- {dbdocs-1.2.0 → dbdocs-1.4.0}/LICENSE +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/__main__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/cli/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/cli/main.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/artifacts.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/config.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/exceptions.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/log.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/column_lineage.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/graph.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/extractor.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/nodes.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/main.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/__init__.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/builder.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/graph/index.css +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/data.js +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/deploy.py +0 -0
- {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/inject.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbdocs
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Alternative dbt docs site: Catalog + ERD + column-level lineage
|
|
5
5
|
Project-URL: Homepage, https://github.com/datnguye/dbt-docs
|
|
6
6
|
Project-URL: Repository, https://github.com/datnguye/dbt-docs
|
|
@@ -16,7 +16,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Requires-Dist: artifact-parser[dbt]>=1.0.0
|
|
18
18
|
Requires-Dist: click>=8.1
|
|
19
|
-
Requires-Dist: dbterd>=1.
|
|
19
|
+
Requires-Dist: dbterd>=1.28
|
|
20
20
|
Requires-Dist: pydantic>=2.13.4
|
|
21
21
|
Requires-Dist: pyyaml>=6.0
|
|
22
22
|
Requires-Dist: sqlglot>=25
|
|
@@ -45,19 +45,17 @@ Turn your dbt artifacts into a self-contained docs site: a browsable catalog, an
|
|
|
45
45
|
|
|
46
46
|
## Why dbdocs?
|
|
47
47
|
|
|
48
|
-
dbt's built-in docs
|
|
49
|
-
|
|
50
|
-
**
|
|
51
|
-
|
|
52
|
-
- **
|
|
53
|
-
- **
|
|
54
|
-
- **
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
- **Versioned deploys, no plugins.** `dbdocs deploy --version v1.2 --alias latest` generates into a plain directory tree, writes a `versions.json` index, and the SPA renders a version dropdown. No mike, no external tooling, no surprise dependencies.
|
|
60
|
-
- **Catalog navigation + client-side search.** Models, seeds, and snapshots grouped by database and schema; full-text search without a backend.
|
|
48
|
+
dbt's built-in docs stop short of telling you *which upstream column fed this downstream column*, *which tables relate to each other*, or *what changed between builds*. dbdocs fills those gaps — no documentation framework or separate ERD tool to install.
|
|
49
|
+
|
|
50
|
+
- **ERD + column-level lineage** — table relationships ([dbterd](https://github.com/datnguye/dbterd)) and column lineage from compiled SQL ([sqlglot](https://github.com/tobymao/sqlglot)).
|
|
51
|
+
- **Column impact analysis** — downstream dependents for any column.
|
|
52
|
+
- **Deep-link URLs** for every node, column, and DAG view.
|
|
53
|
+
- **Any sqlglot dialect**, auto-detected from your manifest.
|
|
54
|
+
- **Scales to 1 000s of models** without freezing the browser.
|
|
55
|
+
- **Fail-soft** — an unparseable model is skipped, not fatal.
|
|
56
|
+
- **Project Health Check** across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions.
|
|
57
|
+
- **Versioned deploys** with a built-in version switcher, no plugins.
|
|
58
|
+
- **Full-text search** across names, columns, descriptions, tags, and SQL at the client-side, no backend.
|
|
61
59
|
- **Dark / light theme.**
|
|
62
60
|
|
|
63
61
|
## Install
|
dbdocs-1.4.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/logo.svg" alt="dbdocs logo" width="220" height="88">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage + versioned deploys, all in one CLI.</b></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://dbdocs.datnguye.me/latest/demo/latest/"><img src="https://img.shields.io/badge/live-demo-FF694A?style=flat&logo=rocket&logoColor=white" alt="live demo"></a>
|
|
9
|
+
<a href="https://dbdocs.datnguye.me/"><img src="https://img.shields.io/badge/docs-visit%20site-blue?style=flat&logo=gitbook&logoColor=white" alt="docs"></a>
|
|
10
|
+
<a href="https://pypi.org/project/dbdocs/"><img src="https://badge.fury.io/py/dbdocs.svg" alt="PyPI version"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/CLI-Python-FFCE3E?labelColor=14354C&logo=python&logoColor=white" alt="python-cli">
|
|
12
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
13
|
+
<a href="https://www.python.org"><img src="https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13-3776AB.svg?style=flat&logo=python&logoColor=white" alt="python"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
Turn your dbt artifacts into a self-contained docs site: a browsable catalog, an entity-relationship diagram, an interactive lineage DAG, and **column-level lineage** traced from your compiled SQL — all in one `dbdocs generate`. Serve it with `dbdocs serve`, or deploy versioned builds anywhere a static host will take them.
|
|
17
|
+
|
|
18
|
+
| Catalog | Model page | Lineage DAG | Health Check |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
|  |  |  |  |
|
|
21
|
+
|
|
22
|
+
## Why dbdocs?
|
|
23
|
+
|
|
24
|
+
dbt's built-in docs stop short of telling you *which upstream column fed this downstream column*, *which tables relate to each other*, or *what changed between builds*. dbdocs fills those gaps — no documentation framework or separate ERD tool to install.
|
|
25
|
+
|
|
26
|
+
- **ERD + column-level lineage** — table relationships ([dbterd](https://github.com/datnguye/dbterd)) and column lineage from compiled SQL ([sqlglot](https://github.com/tobymao/sqlglot)).
|
|
27
|
+
- **Column impact analysis** — downstream dependents for any column.
|
|
28
|
+
- **Deep-link URLs** for every node, column, and DAG view.
|
|
29
|
+
- **Any sqlglot dialect**, auto-detected from your manifest.
|
|
30
|
+
- **Scales to 1 000s of models** without freezing the browser.
|
|
31
|
+
- **Fail-soft** — an unparseable model is skipped, not fatal.
|
|
32
|
+
- **Project Health Check** across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions.
|
|
33
|
+
- **Versioned deploys** with a built-in version switcher, no plugins.
|
|
34
|
+
- **Full-text search** across names, columns, descriptions, tags, and SQL at the client-side, no backend.
|
|
35
|
+
- **Dark / light theme.**
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install dbdocs --upgrade
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.10+.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
dbt docs generate # writes target/manifest.json + target/catalog.json
|
|
49
|
+
dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
|
|
50
|
+
dbdocs serve # static http server on http://127.0.0.1:8000
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
|
|
54
|
+
|
|
55
|
+
Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
|
|
56
|
+
|
|
57
|
+
## Contributing
|
|
58
|
+
|
|
59
|
+
Contributions are welcome — bugs, features, docs, typos. See the **[Contributing Guide](https://dbdocs.datnguye.me/latest/nav/development/contributing-guide.html)**.
|
|
60
|
+
|
|
61
|
+
If dbdocs saves you some clicks, consider [buying me a coffee](https://www.buymeacoffee.com/datnguye).
|
|
62
|
+
|
|
63
|
+
<a href="https://www.buymeacoffee.com/datnguye"><img src="https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?logo=buy-me-a-coffee&logoColor=white&labelColor=ff813f&style=for-the-badge" alt="buy me a coffee"></a>
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
[MIT](./LICENSE) © Dat Nguyen
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Structured ERD data via dbterd's official ``json`` target.
|
|
2
|
+
|
|
3
|
+
dbterd 1.28.0 ships a built-in, schema-validated ``json`` target that emits
|
|
4
|
+
``{nodes, edges, metadata}``. Each node carries ``id`` (the dbt unique_id),
|
|
5
|
+
``name``, ``schema_name``, ``database``, ``resource_type``, and ``columns``
|
|
6
|
+
(with ``data_type``, ``is_primary_key``, ``is_foreign_key``). Each edge carries
|
|
7
|
+
``id``, ``from_id`` (FK/child side), ``to_id`` (referenced/parent side),
|
|
8
|
+
``from_columns``, ``to_columns``, ``label``, and ``cardinality``.
|
|
9
|
+
|
|
10
|
+
``build_erd_data`` maps that shape into the SPA's ``{nodes, edges}`` — the
|
|
11
|
+
React Flow bundle reads ``nodes`` (entities + column flags) and ``edges``
|
|
12
|
+
(FK relationships, ``source``/``target`` keyed by dbt unique_id).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from dbterd.api import DbtErd, default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def erd_algo(dbterd_options: "dict | None" = None) -> str:
|
|
21
|
+
"""The dbterd algorithm that detected the ERD relationships.
|
|
22
|
+
|
|
23
|
+
Reads the configured ``algo`` from the ``dbterd:`` block, falling back to
|
|
24
|
+
dbterd's own default. Surfaced into the SPA so it can explain an empty ERD.
|
|
25
|
+
"""
|
|
26
|
+
return (dbterd_options or {}).get("algo") or default.default_algo()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_erd(dbterd_options: "dict | None" = None, artifacts_dir: "str | None" = None) -> DbtErd:
|
|
30
|
+
"""Build the ERD generator (json target) from dbdocs' ``dbterd`` options.
|
|
31
|
+
|
|
32
|
+
``dbterd_options`` is the ``dbterd:`` block of ``dbdocs.yml`` (``algo``,
|
|
33
|
+
``entity_name_format``, ``resource_type``, ``select``, …) passed straight to
|
|
34
|
+
``DbtErd``. We force ``target="json"`` — the SPA needs structured data — but
|
|
35
|
+
let everything else come from the project's config so the ERD matches what
|
|
36
|
+
the team configured. (Config lives in ``dbdocs.yml``, not a separate
|
|
37
|
+
``.dbterd.yml``.)
|
|
38
|
+
|
|
39
|
+
``artifacts_dir`` is the dbt target dir (``config.target_dir``). dbterd reads
|
|
40
|
+
the manifest/catalog directly from this dir; without it dbterd would default
|
|
41
|
+
to ``./target`` and ignore the configured ``target_dir``. An explicit
|
|
42
|
+
``artifacts_dir`` in ``dbterd_options`` still wins.
|
|
43
|
+
"""
|
|
44
|
+
dbterd_kwargs = {k: v for k, v in (dbterd_options or {}).items() if k != "target"}
|
|
45
|
+
if artifacts_dir is not None:
|
|
46
|
+
dbterd_kwargs.setdefault("artifacts_dir", artifacts_dir)
|
|
47
|
+
return DbtErd(target="json", **dbterd_kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_erd_data(erd: DbtErd) -> dict:
|
|
51
|
+
"""Parse dbterd's official ``{nodes, edges}`` payload into the SPA shape.
|
|
52
|
+
|
|
53
|
+
dbterd's ``json`` target emits nodes keyed by dbt unique_id (``id`` field).
|
|
54
|
+
When ``entity_name_format`` is configured, dbterd emits edge ``from_id`` /
|
|
55
|
+
``to_id`` as the formatted entity name (e.g. ``orders``) rather than the
|
|
56
|
+
full unique_id (e.g. ``model.jaffle_shop.orders``). A ``name_to_id`` map
|
|
57
|
+
resolves those short names back to the node id before building edges, so the
|
|
58
|
+
SPA's ``source``/``target`` always reference a valid node ``id``.
|
|
59
|
+
|
|
60
|
+
Some dbterd algos (e.g. ``model_contract``) do not set ``is_foreign_key``
|
|
61
|
+
on node columns even when those columns appear in FK edges. After building
|
|
62
|
+
edges, any column named in an edge's ``from_columns`` (the FK/child side)
|
|
63
|
+
has its ``is_foreign_key`` flag back-filled to ``True`` on the target node.
|
|
64
|
+
|
|
65
|
+
SPA edge direction: ``source`` is the referenced (parent) side, ``target``
|
|
66
|
+
is the FK (child) side — matching dbterd's ``to_id`` and ``from_id``
|
|
67
|
+
respectively.
|
|
68
|
+
"""
|
|
69
|
+
payload = json.loads(erd.get_erd())
|
|
70
|
+
raw_nodes = payload.get("nodes", [])
|
|
71
|
+
raw_edges = payload.get("edges", [])
|
|
72
|
+
nodes = [_build_node(n) for n in raw_nodes]
|
|
73
|
+
node_ids = {n["id"] for n in nodes}
|
|
74
|
+
# Count occurrences first; ambiguous names (more than one node) are excluded
|
|
75
|
+
# so a collision can't silently resolve to the wrong node.
|
|
76
|
+
name_counts: dict[str, int] = {}
|
|
77
|
+
for n in raw_nodes:
|
|
78
|
+
nm = n.get("name")
|
|
79
|
+
if nm:
|
|
80
|
+
name_counts[nm] = name_counts.get(nm, 0) + 1
|
|
81
|
+
name_to_id = {
|
|
82
|
+
n.get("name"): n["id"]
|
|
83
|
+
for n in raw_nodes
|
|
84
|
+
if n.get("name") and name_counts[n.get("name")] == 1
|
|
85
|
+
}
|
|
86
|
+
edges = [_build_edge(e, i, node_ids, name_to_id) for i, e in enumerate(raw_edges)]
|
|
87
|
+
_backfill_fk_flags(nodes, edges)
|
|
88
|
+
return {"nodes": nodes, "edges": edges}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _backfill_fk_flags(nodes: "list[dict]", edges: "list[dict]") -> None:
|
|
92
|
+
"""Set is_foreign_key=True on columns named in each edge's from_columns.
|
|
93
|
+
|
|
94
|
+
Keyed by node id so the lookup is O(1) per column per edge.
|
|
95
|
+
"""
|
|
96
|
+
nodes_by_id = {n["id"]: n for n in nodes}
|
|
97
|
+
for edge in edges:
|
|
98
|
+
target_node = nodes_by_id.get(edge["target"])
|
|
99
|
+
if target_node is None:
|
|
100
|
+
continue
|
|
101
|
+
fk_cols = {c.lower() for c in edge.get("from_columns", [])}
|
|
102
|
+
if not fk_cols:
|
|
103
|
+
continue
|
|
104
|
+
for col in target_node["columns"]:
|
|
105
|
+
if col["name"].lower() in fk_cols:
|
|
106
|
+
col["is_foreign_key"] = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_node(node: dict) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"id": node["id"],
|
|
112
|
+
"label": node.get("name") or "",
|
|
113
|
+
"database": node.get("database") or "",
|
|
114
|
+
"schema": node.get("schema_name") or "",
|
|
115
|
+
"resource_type": node.get("resource_type") or "model",
|
|
116
|
+
"columns": [
|
|
117
|
+
{
|
|
118
|
+
"name": c["name"],
|
|
119
|
+
"type": c.get("data_type") or "",
|
|
120
|
+
"description": c.get("description") or "",
|
|
121
|
+
"is_primary_key": bool(c.get("is_primary_key")),
|
|
122
|
+
"is_foreign_key": bool(c.get("is_foreign_key")),
|
|
123
|
+
}
|
|
124
|
+
for c in node.get("columns", [])
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_edge_id(raw: str, node_ids: "set[str]", name_to_id: "dict[str, str]") -> str:
|
|
130
|
+
# If raw is already a valid node id, keep it (the no-entity_name_format case).
|
|
131
|
+
# Otherwise resolve through the name→id map built from node labels.
|
|
132
|
+
if raw in node_ids:
|
|
133
|
+
return raw
|
|
134
|
+
return name_to_id.get(raw, raw)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_edge(edge: dict, index: int, node_ids: "set[str]", name_to_id: "dict[str, str]") -> dict:
|
|
138
|
+
# from_id is the FK/child side; to_id is the referenced/parent side.
|
|
139
|
+
# SPA convention: source = parent (to_id), target = child (from_id).
|
|
140
|
+
raw_from = edge.get("from_id") or ""
|
|
141
|
+
raw_to = edge.get("to_id") or ""
|
|
142
|
+
return {
|
|
143
|
+
"id": edge.get("id") or f"e{index}",
|
|
144
|
+
"source": _resolve_edge_id(raw_to, node_ids, name_to_id),
|
|
145
|
+
"target": _resolve_edge_id(raw_from, node_ids, name_to_id),
|
|
146
|
+
"from_columns": edge.get("from_columns") or [],
|
|
147
|
+
"to_columns": edge.get("to_columns") or [],
|
|
148
|
+
"label": edge.get("label") or "",
|
|
149
|
+
"type": edge.get("cardinality") or "",
|
|
150
|
+
}
|
|
@@ -50,6 +50,13 @@ class ManifestGraph:
|
|
|
50
50
|
self.models = [n for uid, n in self._nodes.items() if uid.startswith("model.")]
|
|
51
51
|
self.sources = list(self._sources.values())
|
|
52
52
|
self.exposures = list(self._exposures.values())
|
|
53
|
+
# Singular tests are custom-SQL test nodes (no test_metadata); generic
|
|
54
|
+
# tests (unique/not_null/…) carry test_metadata and are excluded.
|
|
55
|
+
self.singular_tests = [
|
|
56
|
+
n
|
|
57
|
+
for uid, n in self._nodes.items()
|
|
58
|
+
if uid.startswith("test.") and getattr(n, "test_metadata", None) is None
|
|
59
|
+
]
|
|
53
60
|
|
|
54
61
|
# Rule thresholds: per-run overrides layered over the DPE defaults.
|
|
55
62
|
self._thresholds = {**DEFAULT_THRESHOLDS, **(thresholds or {})}
|
|
@@ -147,6 +154,21 @@ class ManifestGraph:
|
|
|
147
154
|
access = access or getattr(model, "access", None)
|
|
148
155
|
return str(access or "protected").lower()
|
|
149
156
|
|
|
157
|
+
@staticmethod
|
|
158
|
+
def has_source_freshness(source: Any) -> bool:
|
|
159
|
+
"""Whether a source has a freshness check: a ``loaded_at_field`` plus a
|
|
160
|
+
``warn_after``/``error_after`` threshold count."""
|
|
161
|
+
if not str(getattr(source, "loaded_at_field", "") or "").strip():
|
|
162
|
+
return False
|
|
163
|
+
freshness = getattr(source, "freshness", None)
|
|
164
|
+
if freshness is None:
|
|
165
|
+
return False
|
|
166
|
+
for bound in ("warn_after", "error_after"):
|
|
167
|
+
period = getattr(freshness, bound, None)
|
|
168
|
+
if period is not None and getattr(period, "count", None) is not None:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
150
172
|
@staticmethod
|
|
151
173
|
def contract_enforced(model: Any) -> bool:
|
|
152
174
|
"""Whether the model has an enforced contract (``contract.enforced``)."""
|
|
@@ -24,6 +24,7 @@ of :mod:`base` and :mod:`registry`; no implementation lives here.
|
|
|
24
24
|
|
|
25
25
|
from dbdocs.extract.health.rules.base import DEFAULT_THRESHOLDS, NON_PHYSICAL, docs_url, finding
|
|
26
26
|
from dbdocs.extract.health.rules.dimensions.documentation import (
|
|
27
|
+
documentation_coverage,
|
|
27
28
|
undocumented_models,
|
|
28
29
|
undocumented_source_tables,
|
|
29
30
|
undocumented_sources,
|
|
@@ -35,7 +36,9 @@ from dbdocs.extract.health.rules.dimensions.governance import (
|
|
|
35
36
|
)
|
|
36
37
|
from dbdocs.extract.health.rules.dimensions.modeling import (
|
|
37
38
|
direct_join_to_source,
|
|
39
|
+
downstream_models_dependent_on_source,
|
|
38
40
|
duplicate_sources,
|
|
41
|
+
hard_coded_references,
|
|
39
42
|
model_fanout,
|
|
40
43
|
multiple_sources_joined,
|
|
41
44
|
rejoining_of_upstream_concepts,
|
|
@@ -54,8 +57,13 @@ from dbdocs.extract.health.rules.dimensions.structure import (
|
|
|
54
57
|
model_directories,
|
|
55
58
|
model_naming_conventions,
|
|
56
59
|
source_directories,
|
|
60
|
+
test_directories,
|
|
61
|
+
)
|
|
62
|
+
from dbdocs.extract.health.rules.dimensions.testing import (
|
|
63
|
+
missing_primary_key_tests,
|
|
64
|
+
missing_source_freshness,
|
|
65
|
+
test_coverage,
|
|
57
66
|
)
|
|
58
|
-
from dbdocs.extract.health.rules.dimensions.testing import missing_primary_key_tests, test_coverage
|
|
59
67
|
from dbdocs.extract.health.rules.registry import (
|
|
60
68
|
DIMENSION_RULES,
|
|
61
69
|
ENTRY_POINT_GROUP,
|
|
@@ -81,8 +89,11 @@ __all__ = [
|
|
|
81
89
|
# rule functions (re-exported for direct access / testing)
|
|
82
90
|
"test_coverage",
|
|
83
91
|
"missing_primary_key_tests",
|
|
92
|
+
"missing_source_freshness",
|
|
84
93
|
"direct_join_to_source",
|
|
94
|
+
"downstream_models_dependent_on_source",
|
|
85
95
|
"duplicate_sources",
|
|
96
|
+
"hard_coded_references",
|
|
86
97
|
"model_fanout",
|
|
87
98
|
"multiple_sources_joined",
|
|
88
99
|
"rejoining_of_upstream_concepts",
|
|
@@ -92,12 +103,14 @@ __all__ = [
|
|
|
92
103
|
"staging_dependent_on_marts_or_intermediate",
|
|
93
104
|
"unused_sources",
|
|
94
105
|
"too_many_joins",
|
|
106
|
+
"documentation_coverage",
|
|
95
107
|
"undocumented_models",
|
|
96
108
|
"undocumented_sources",
|
|
97
109
|
"undocumented_source_tables",
|
|
98
110
|
"model_naming_conventions",
|
|
99
111
|
"model_directories",
|
|
100
112
|
"source_directories",
|
|
113
|
+
"test_directories",
|
|
101
114
|
"chained_view_dependencies",
|
|
102
115
|
"exposure_parents_materializations",
|
|
103
116
|
"public_models_without_contracts",
|
|
@@ -14,6 +14,16 @@ DEFAULT_THRESHOLDS = {
|
|
|
14
14
|
"model_fanout": 3,
|
|
15
15
|
"too_many_joins": 7,
|
|
16
16
|
"chained_view_dependencies": 4,
|
|
17
|
+
"documentation_coverage": 100,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# DPE anchors that don't equal the rule name kebab-cased. The auto-derived anchor
|
|
21
|
+
# (``rule.replace("_", "-")``) is the DPE heading for most rules; these few have a
|
|
22
|
+
# different published heading, so map the rule name to its real anchor explicitly.
|
|
23
|
+
_RULE_ANCHORS = {
|
|
24
|
+
"too_many_joins": "models-with-too-many-joins",
|
|
25
|
+
"staging_dependent_on_marts_or_intermediate": "staging-models-dependent-on-downstream-models",
|
|
26
|
+
"staging_dependent_on_staging": "staging-models-dependent-on-other-staging-models",
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
# Materializations that are *not* a physical table (for chained-view detection).
|
|
@@ -28,8 +38,13 @@ LAYER_PREFIXES = {
|
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def docs_url(category: str, rule: str) -> str:
|
|
31
|
-
"""The DPE docs URL for a rule under *category
|
|
32
|
-
|
|
41
|
+
"""The DPE docs URL for a rule under *category*.
|
|
42
|
+
|
|
43
|
+
The anchor is the rule name kebab-cased, except for the few rules whose DPE
|
|
44
|
+
heading differs (see ``_RULE_ANCHORS``).
|
|
45
|
+
"""
|
|
46
|
+
anchor = _RULE_ANCHORS.get(rule, rule.replace("_", "-"))
|
|
47
|
+
return f"{_RULES_BASE}/{category}/#{anchor}"
|
|
33
48
|
|
|
34
49
|
|
|
35
50
|
def finding(rule: str, category: str, node: str, node_type: str, message: str) -> dict:
|
|
@@ -54,6 +54,36 @@ def undocumented_sources(graph: "ManifestGraph") -> "list[dict]":
|
|
|
54
54
|
return out
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def documentation_coverage(graph: "ManifestGraph") -> "list[dict]":
|
|
58
|
+
"""One project-level finding when the share of documented models is below target.
|
|
59
|
+
|
|
60
|
+
Mirrors DPE's ``fct_documentation_coverage`` — an aggregate percentage rather
|
|
61
|
+
than a per-model flag. The ``documentation_coverage`` threshold (default 100)
|
|
62
|
+
is the minimum acceptable percentage; coverage at or above it is clean.
|
|
63
|
+
|
|
64
|
+
Being an aggregate, its finding's ``node`` is the pseudo-id ``"project"`` (not a
|
|
65
|
+
real ``unique_id``); the SPA renders an unresolvable node as plain text rather
|
|
66
|
+
than a node-page link, so this degrades gracefully.
|
|
67
|
+
"""
|
|
68
|
+
models = graph.models
|
|
69
|
+
if not models:
|
|
70
|
+
return []
|
|
71
|
+
documented = sum(1 for m in models if str(getattr(m, "description", "") or "").strip())
|
|
72
|
+
coverage = round(documented * 100 / len(models), 1)
|
|
73
|
+
if coverage >= graph.threshold("documentation_coverage"):
|
|
74
|
+
return []
|
|
75
|
+
return [
|
|
76
|
+
finding(
|
|
77
|
+
"documentation_coverage",
|
|
78
|
+
"documentation",
|
|
79
|
+
"project",
|
|
80
|
+
"project",
|
|
81
|
+
f"Only {coverage}% of models are documented "
|
|
82
|
+
f"({documented}/{len(models)}) — below the configured target.",
|
|
83
|
+
)
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
57
87
|
def undocumented_source_tables(graph: "ManifestGraph") -> "list[dict]":
|
|
58
88
|
"""Source tables (the individual relations) with no description."""
|
|
59
89
|
out = []
|
|
@@ -4,14 +4,29 @@ Each rule is ``(graph) -> list[dict]``; see ``dbdocs.extract.health.rules``
|
|
|
4
4
|
for the shared finding shape and the registry that assembles these.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import re
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
from sqlglot.errors import SqlglotError
|
|
13
|
+
from sqlglot.optimizer.scope import build_scope
|
|
14
|
+
|
|
9
15
|
from dbdocs.extract.health.rules.base import finding
|
|
10
16
|
|
|
11
17
|
# ``ManifestGraph`` annotations are strings (forward ref) to avoid a circular
|
|
12
18
|
# import (rules ← graph ← analyzer); resolved lazily by the type checker only.
|
|
13
19
|
ManifestGraph = Any
|
|
14
20
|
|
|
21
|
+
# A ``{{ ref(...) }}`` / ``{{ source(...) }}`` call is rewritten to a uniquely
|
|
22
|
+
# numbered sentinel table before parsing (``__dbt_ref_0__``, ``__dbt_ref_1__``, …)
|
|
23
|
+
# — unique so sqlglot's scope analysis doesn't choke on a repeated alias — so any
|
|
24
|
+
# *other* real table left in the SQL is a hard-coded relation. Any remaining
|
|
25
|
+
# ``{{ ... }}`` macro is blanked so it doesn't masquerade as a table.
|
|
26
|
+
_REF_SENTINEL_PREFIX = "__dbt_ref_"
|
|
27
|
+
_REF_OR_SOURCE = re.compile(r"{{[-\s]*(?:ref|source)\s*\(.*?\)[-\s]*}}", re.IGNORECASE | re.DOTALL)
|
|
28
|
+
_OTHER_JINJA = re.compile(r"{[{%].*?[%}]}", re.DOTALL)
|
|
29
|
+
|
|
15
30
|
|
|
16
31
|
def direct_join_to_source(graph: "ManifestGraph") -> "list[dict]":
|
|
17
32
|
"""Models that ref a model AND a source in the same query (missing staging)."""
|
|
@@ -121,6 +136,87 @@ def rejoining_of_upstream_concepts(graph: "ManifestGraph") -> "list[dict]":
|
|
|
121
136
|
return out
|
|
122
137
|
|
|
123
138
|
|
|
139
|
+
def downstream_models_dependent_on_source(graph: "ManifestGraph") -> "list[dict]":
|
|
140
|
+
"""Non-staging models that ref a source directly (only staging should touch sources)."""
|
|
141
|
+
out = []
|
|
142
|
+
for model in graph.models:
|
|
143
|
+
if graph.layer(model) == "staging":
|
|
144
|
+
continue
|
|
145
|
+
if any(p.startswith("source.") for p in graph.parents(model.unique_id)):
|
|
146
|
+
out.append(
|
|
147
|
+
finding(
|
|
148
|
+
"downstream_models_dependent_on_source",
|
|
149
|
+
"modeling",
|
|
150
|
+
model.unique_id,
|
|
151
|
+
"model",
|
|
152
|
+
"A non-staging model selects from a source — route it through a staging model.",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _number_refs(raw_code: str) -> str:
|
|
159
|
+
"""Replace each ref()/source() jinja call with a uniquely numbered sentinel."""
|
|
160
|
+
out = []
|
|
161
|
+
last = 0
|
|
162
|
+
for index, match in enumerate(_REF_OR_SOURCE.finditer(raw_code)):
|
|
163
|
+
out.append(raw_code[last : match.start()])
|
|
164
|
+
out.append(f"{_REF_SENTINEL_PREFIX}{index}__")
|
|
165
|
+
last = match.end()
|
|
166
|
+
out.append(raw_code[last:])
|
|
167
|
+
return "".join(out)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _hard_coded_relations(raw_code: str) -> "list[str]":
|
|
171
|
+
"""Real table names a model's raw SQL selects from outside ref()/source().
|
|
172
|
+
|
|
173
|
+
dbt references are jinja (``{{ ref(...) }}`` / ``{{ source(...) }}``), so they
|
|
174
|
+
are rewritten to uniquely numbered sentinel tables before parsing (so sqlglot's
|
|
175
|
+
scope analysis doesn't choke on a repeated alias); any other real table its
|
|
176
|
+
scope analysis finds is a hard-coded relation. CTEs are excluded by walking
|
|
177
|
+
``scope.selected_sources`` rather than every ``exp.Table``. Unparseable SQL
|
|
178
|
+
yields nothing (fail-soft — one model never sinks the pass).
|
|
179
|
+
"""
|
|
180
|
+
sql = _number_refs(raw_code)
|
|
181
|
+
sql = _OTHER_JINJA.sub(" ", sql)
|
|
182
|
+
if not sql.strip():
|
|
183
|
+
return []
|
|
184
|
+
try:
|
|
185
|
+
# parse_one returns a node or raises for non-empty input (empty is guarded
|
|
186
|
+
# above); build_scope returns None for a non-query statement (DDL/SET).
|
|
187
|
+
root = build_scope(sqlglot.parse_one(sql))
|
|
188
|
+
if root is None:
|
|
189
|
+
return []
|
|
190
|
+
relations = [
|
|
191
|
+
source.sql()
|
|
192
|
+
for scope in root.traverse()
|
|
193
|
+
for _alias, (_node, source) in scope.selected_sources.items()
|
|
194
|
+
if isinstance(source, exp.Table) and not source.name.startswith(_REF_SENTINEL_PREFIX)
|
|
195
|
+
]
|
|
196
|
+
except SqlglotError: # OptimizeError (e.g. duplicate alias) is a SqlglotError subclass.
|
|
197
|
+
return []
|
|
198
|
+
return relations
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def hard_coded_references(graph: "ManifestGraph") -> "list[dict]":
|
|
202
|
+
"""Models whose SQL selects from a literal relation instead of ref()/source()."""
|
|
203
|
+
out = []
|
|
204
|
+
for model in graph.models:
|
|
205
|
+
relations = _hard_coded_relations(str(getattr(model, "raw_code", "") or ""))
|
|
206
|
+
if relations:
|
|
207
|
+
shown = ", ".join(sorted(set(relations)))
|
|
208
|
+
out.append(
|
|
209
|
+
finding(
|
|
210
|
+
"hard_coded_references",
|
|
211
|
+
"modeling",
|
|
212
|
+
model.unique_id,
|
|
213
|
+
"model",
|
|
214
|
+
f"Hard-coded relation(s) {shown} — replace with ref()/source().",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
124
220
|
def root_models(graph: "ManifestGraph") -> "list[dict]":
|
|
125
221
|
"""Models with zero parents (no ref/source) — untraceable lineage."""
|
|
126
222
|
out = []
|
|
@@ -57,6 +57,24 @@ def model_directories(graph: "ManifestGraph") -> "list[dict]":
|
|
|
57
57
|
return out
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_directories(graph: "ManifestGraph") -> "list[dict]":
|
|
61
|
+
"""Singular (custom SQL) tests not stored under a ``tests/`` directory."""
|
|
62
|
+
out = []
|
|
63
|
+
for test in graph.singular_tests:
|
|
64
|
+
path = str(getattr(test, "original_file_path", "") or getattr(test, "path", "") or "")
|
|
65
|
+
if "tests" not in path.split("/"):
|
|
66
|
+
out.append(
|
|
67
|
+
finding(
|
|
68
|
+
"test_directories",
|
|
69
|
+
"structure",
|
|
70
|
+
test.unique_id,
|
|
71
|
+
"test",
|
|
72
|
+
"Singular test is not under a 'tests/' directory.",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
60
78
|
def source_directories(graph: "ManifestGraph") -> "list[dict]":
|
|
61
79
|
"""Source YAML not under a staging/ directory."""
|
|
62
80
|
out = []
|
|
@@ -49,3 +49,22 @@ def missing_primary_key_tests(graph: "ManifestGraph") -> "list[dict]":
|
|
|
49
49
|
)
|
|
50
50
|
)
|
|
51
51
|
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def missing_source_freshness(graph: "ManifestGraph") -> "list[dict]":
|
|
55
|
+
"""Sources with no freshness check (no ``loaded_at_field`` + warn/error threshold)."""
|
|
56
|
+
out = []
|
|
57
|
+
for src in graph.sources:
|
|
58
|
+
if not graph.has_source_freshness(src):
|
|
59
|
+
name = str(getattr(src, "source_name", "") or "") or src.unique_id
|
|
60
|
+
table = str(getattr(src, "name", "") or "")
|
|
61
|
+
out.append(
|
|
62
|
+
finding(
|
|
63
|
+
"missing_source_freshness",
|
|
64
|
+
"testing",
|
|
65
|
+
src.unique_id,
|
|
66
|
+
"source",
|
|
67
|
+
f"Source '{name}.{table}' has no freshness check configured.",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return out
|