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.
Files changed (53) hide show
  1. {dbdocs-1.2.0 → dbdocs-1.4.0}/.gitignore +2 -0
  2. {dbdocs-1.2.0 → dbdocs-1.4.0}/PKG-INFO +13 -15
  3. dbdocs-1.4.0/README.md +67 -0
  4. dbdocs-1.4.0/dbdocs/extract/erd.py +150 -0
  5. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/dimensions.py +22 -0
  6. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/__init__.py +14 -1
  7. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/base.py +17 -2
  8. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/documentation.py +30 -0
  9. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/modeling.py +96 -0
  10. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/structure.py +18 -0
  11. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/testing.py +19 -0
  12. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/registry.py +14 -1
  13. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/css/style.css +19 -0
  14. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/graph/index.js +7 -7
  15. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/service.js +169 -1
  16. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/ui.js +137 -17
  17. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/index.html +3 -2
  18. {dbdocs-1.2.0 → dbdocs-1.4.0}/pyproject.toml +3 -2
  19. dbdocs-1.2.0/README.md +0 -69
  20. dbdocs-1.2.0/dbdocs/extract/erd.py +0 -111
  21. dbdocs-1.2.0/dbdocs/extract/erd_json.py +0 -80
  22. {dbdocs-1.2.0 → dbdocs-1.4.0}/LICENSE +0 -0
  23. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/__init__.py +0 -0
  24. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/__main__.py +0 -0
  25. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/cli/__init__.py +0 -0
  26. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/cli/main.py +0 -0
  27. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/__init__.py +0 -0
  28. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/artifacts.py +0 -0
  29. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/config.py +0 -0
  30. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/exceptions.py +0 -0
  31. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/core/log.py +0 -0
  32. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/__init__.py +0 -0
  33. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  34. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/column_lineage.py +0 -0
  35. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/graph.py +0 -0
  36. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/__init__.py +0 -0
  37. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/extractor.py +0 -0
  38. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
  39. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
  40. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
  41. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/extract/nodes.py +0 -0
  42. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/main.py +0 -0
  43. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/__init__.py +0 -0
  44. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/builder.py +0 -0
  45. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
  46. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  47. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/graph/index.css +0 -0
  48. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
  49. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/js/data.js +0 -0
  50. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  51. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  52. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/deploy.py +0 -0
  53. {dbdocs-1.2.0 → dbdocs-1.4.0}/dbdocs/site/inject.py +0 -0
@@ -154,6 +154,8 @@ frontend/coverage/
154
154
  frontend/*.tsbuildinfo
155
155
  frontend/test-results/
156
156
  frontend/playwright-report/
157
+ test-results/
158
+ playwright-report/
157
159
  .eslintcache
158
160
 
159
161
  # npm / yarn / pnpm logs & debug output
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbdocs
3
- Version: 1.2.0
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.27
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 are great — right up until you want to know *which upstream column fed this downstream column*, or *which tables relate to each other*, or *what changed between last week's docs and today's*. dbdocs fills all three gaps without asking you to install a documentation framework or maintain a separate ERD tool.
49
-
50
- **What you get that nothing else bundles together:**
51
-
52
- - **ERD + column-level lineage, side by side.** The entity-relationship diagram (powered by [dbterd](https://github.com/datnguye/dbterd)) shows table relationships; column lineage (traced by [sqlglot](https://github.com/tobymao/sqlglot) from compiled SQL) shows exactly which column fed which. Most alternatives give you one or the other — dbdocs gives you both.
53
- - **Column impact analysis.** Select any column and see its downstream dependents across the project, so you know what a schema change will break before you run it.
54
- - **Deep-link URLs.** Every focused node, column, and filtered DAG view has a shareable URL. Paste it in Slack and your teammate lands on exactly the right model, column, or graph state.
55
- - **Any sqlglot-supported dialect.** The dialect for column-lineage parsing is auto-detected from your manifest's `adapter_type` (Snowflake, BigQuery, Redshift, DuckDB, PostgreSQL, Databricks/Spark, Trino, and more — anything sqlglot understands). Override it per-project with `dialect:` in `dbdocs.yml` when auto-detection isn't enough.
56
- - **Scales without freezing.** Column-lineage parsing fans out across CPU cores automatically above 500 models, so large projects finish in roughly the same wall-clock time as small ones. The DAG is windowed by React Flow, so a 1 000-model graph doesn't turn your browser into a space heater. The payload ships as an external gzip (`dbdocs-data.json.gz`, decompressed client-side) so `index.html` stays tiny regardless of project size.
57
- - **Fail-soft.** One model with SQL sqlglot can't parse gets skipped and logged — it never sinks the whole generate run.
58
- - **Project Health Check.** A scorecard across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions (testing, modeling, documentation, structure, performance, governance), computed straight from your `manifest.json` — no extra dbt package, no warehouse. When a `run_results.json` is also present (any `dbt build`/`dbt test`; default `<target_dir>/run_results.json`, override with `--run-results`), each test additionally shows up as a pass/fail finding grouped by what it checks (integrity, referential, validity, business logic, freshness). dbdocs only reads the artifacts — it never runs dbt or touches your warehouse. Fail-soft: a missing `run_results.json` just drops the per-test detail; the dimensions still render.
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
+ | ![catalog](docs/assets/img/demo-catalog.png) | ![model page](docs/assets/img/demo-model-page.png) | ![dag](docs/assets/img/demo-model-dag.png) | ![health check](docs/assets/img/demo-health.png) |
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* (anchor = the rule name)."""
32
- return f"{_RULES_BASE}/{category}/#{rule.replace('_', '-')}"
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