|
| 1 | +# Documenting models and columns |
| 2 | + |
| 3 | +Every model, source table and column carries a description, published to the dbt docs site on |
| 4 | +every merge to `master` (`.github/workflows/dbt-docs-website.yml`). |
| 5 | + |
| 6 | +- [The rule](#the-rule) |
| 7 | +- [Where a block lives, and what to call it](#where-a-block-lives-and-what-to-call-it) |
| 8 | +- [Adding a column](#adding-a-column) |
| 9 | +- [Running the linter](#running-the-linter) |
| 10 | +- [What the linter does not catch](#what-the-linter-does-not-catch) |
| 11 | +- [Proving a change is docs-neutral](#proving-a-change-is-docs-neutral) |
| 12 | +- [File conventions](#file-conventions) |
| 13 | +- [Relationship to stellar-dbt](#relationship-to-stellar-dbt) |
| 14 | + |
| 15 | +## The rule |
| 16 | + |
| 17 | +**Descriptions live in doc blocks, never inline in yml.** The yml holds a reference: |
| 18 | + |
| 19 | +```yaml |
| 20 | +- name: asset_code |
| 21 | + description: '{{ doc("asset_code") }}' |
| 22 | +``` |
| 23 | +
|
| 24 | +and the text lives in a `.md` under `models/docs/`: |
| 25 | + |
| 26 | +```markdown |
| 27 | +{% docs asset_code %} |
| 28 | +The 4 or 12 character code representation of the asset on the network. |
| 29 | +{% enddocs %} |
| 30 | +``` |
| 31 | + |
| 32 | +No allowlist, no per-description exception, no way to defer a path, no threshold to tune. |
| 33 | +`scripts/docs_lint.py check` enforces it and runs in pre-commit. |
| 34 | + |
| 35 | +## Where a block lives, and what to call it |
| 36 | + |
| 37 | +`models/docs/` mirrors `models/`: a column on `models/marts/trade_agg.sql` is documented in |
| 38 | +`models/docs/marts/trade_agg.md`. Create the mirror file if it does not exist. |
| 39 | + |
| 40 | +A definition that **unrelated tables** share goes in `models/docs/universal.md` instead, named |
| 41 | +after the bare column (`asset_code`, `batch_run_date`, `closed_at`). A column that merely flows |
| 42 | +`sources/` -> `staging/` -> `marts/` touches several files but is still one table's column, so |
| 43 | +it stays in that table's mirror file. |
| 44 | + |
| 45 | +A definition specific to one model is named `<model>__<column>`, for example |
| 46 | +`int_tvl_trustlines__asset_code` because that column carries non-XLM TVL only. Use the scoped |
| 47 | +form whenever a column's meaning differs from the shared block of the same name, even slightly. |
| 48 | +`amount_raw` (i128 base units) and `amount` (decimals applied) are different concepts and get |
| 49 | +different blocks. |
| 50 | + |
| 51 | +Block names are globally unique across the project and resolve by name, not by file, so moving |
| 52 | +a block between files is always safe. |
| 53 | + |
| 54 | +`check` enforces the root: a definition this repo owns must live somewhere under `models/docs/`. |
| 55 | +Which file it lands in inside that root is convention, not enforced, so the mirror rule above is |
| 56 | +guidance rather than a gate. `docs_lint.py report` shows how many tables use each block if you |
| 57 | +want to check whether something has outgrown its home. |
| 58 | + |
| 59 | +## Adding a column |
| 60 | + |
| 61 | +1. Add the column to the model's `.yml` alongside its tests. |
| 62 | +2. Look for an existing block: `grep -rn "{% docs <column_name> %}" models/docs/` |
| 63 | +3. If one exists, read its text and confirm it describes your column. If it does not, write a |
| 64 | + new block named `<model>__<column>`. |
| 65 | +4. Otherwise write a block in the model's mirror `.md`. |
| 66 | +5. Run `docs_lint.py check`. |
| 67 | + |
| 68 | +## Running the linter |
| 69 | + |
| 70 | +No warehouse connection or credentials needed; it reads the yml and md files directly. |
| 71 | + |
| 72 | +```bash |
| 73 | +./venv/bin/python scripts/docs_lint.py check # the gate; also runs in pre-commit |
| 74 | +./venv/bin/python scripts/docs_lint.py report # diagnostics, never fails a build |
| 75 | +``` |
| 76 | + |
| 77 | +`check` requires that the whole value of a `description:` is one `doc()` call naming a block that |
| 78 | +exists, so it fails on inline text, on text wrapped around a reference |
| 79 | +(`see {{ doc("x") }} for detail`), on an empty description, on two references in one value, and |
| 80 | +on a name that is not defined anywhere. It also fails on a yml that will not parse (the rule |
| 81 | +cannot be applied to a file that cannot be read) on two files defining the same block name |
| 82 | +(dbt cannot resolve a duplicate), and on a definition this repo owns that sits outside |
| 83 | +`models/docs/`, which is how a definition drifts somewhere nobody thinks to look. |
| 84 | + |
| 85 | +**Macro and argument descriptions are out of scope.** They document one macro's signature, so |
| 86 | +there is nothing to factor out, and moving them into `models/docs/` would only put a macro's API |
| 87 | +docs further from the macro. |
| 88 | + |
| 89 | +`report` lists orphan blocks, how many tables use each block, columns declared twice under one |
| 90 | +resource, and any column name that resolves to more than one description. |
| 91 | + |
| 92 | +## What the linter does not catch |
| 93 | + |
| 94 | +**A `doc()` that points at the wrong block.** The rule checks that a description *is* a |
| 95 | +reference, not that the reference is *correct*, so both of these pass: |
| 96 | + |
| 97 | +```yaml |
| 98 | +- name: asset_b_type |
| 99 | + description: '{{ doc("asset_a_type") }}' # renders "the sold asset", not "the bought asset" |
| 100 | +- name: operation_id |
| 101 | + description: '{{ doc("transaction_id") }}' # renders "a unique identifier for this transaction" |
| 102 | +``` |
| 103 | + |
| 104 | +Both of those are live in this repo right now, along with nineteen more. They come from copying |
| 105 | +a neighbouring line and cluster on paired columns: `asset_a` / `asset_b`, `read_bytes` / |
| 106 | +`write_bytes`, `batch_id` / `batch_run_date`. Repairing them changes published text, so it is |
| 107 | +tracked separately in #326 and deliberately not part of the change that added this linter. |
| 108 | + |
| 109 | +This is a code review responsibility. When reviewing a description change, read the block that |
| 110 | +is referenced rather than trusting its name. The last section of `docs_lint.py report` helps: a |
| 111 | +column that disagrees with itself inside one table family is nearly always one of these. |
| 112 | + |
| 113 | +**The same column declared twice in one yml.** yaml keeps the last entry, so the first |
| 114 | +description is dropped and the column publishes the second one's text. dbt does not warn. |
| 115 | +`report` lists these; five remain, and `enriched_history_operations_soroban.memo_type` was one |
| 116 | +of them, which is why `memo` had no description at all. |
| 117 | + |
| 118 | +## Proving a change is docs-neutral |
| 119 | + |
| 120 | +Moving blocks between files should not change a single rendered description. Prove it: |
| 121 | + |
| 122 | +```bash |
| 123 | +git stash push --include-untracked # -u matters: a new untracked .yml or .md |
| 124 | +./venv/bin/python scripts/docs_lint.py snapshot --out /tmp/before.json |
| 125 | +git stash pop |
| 126 | +./venv/bin/python scripts/docs_lint.py snapshot --out /tmp/after.json |
| 127 | +./venv/bin/python scripts/docs_lint.py diff /tmp/before.json /tmp/after.json |
| 128 | +``` |
| 129 | + |
| 130 | +A pure relocation must print `0 differences`; a text change must print exactly what you meant |
| 131 | +to change. Paste that output into the PR: it is the cheapest way to show a reviewer that a large |
| 132 | +diff is safe. |
| 133 | + |
| 134 | +The snapshot is trustworthy because the resolver is checked against dbt itself with |
| 135 | +`docs_lint.py validate-manifest`, which needs a `target/manifest.json` from `dbt parse` or |
| 136 | +`dbt docs generate`. It exits non-zero unless `MISMATCHED` is 0, nothing is unresolved, and every |
| 137 | +description it could not compare is one an installed package patched in, which this repo cannot |
| 138 | +see by design. |
| 139 | + |
| 140 | +One thing that looks like it should work and does not: **a `doc()` call inside a doc block.** dbt |
| 141 | +renders block bodies without the `doc` macro in scope and fails the parse with |
| 142 | +`'doc' is undefined`, so a shared definition cannot be wrapped with a local qualifier. Write a |
| 143 | +scoped `<model>__<column>` block with the full text instead. |
| 144 | +## File conventions |
| 145 | + |
| 146 | +- One `.md` per model or source table, mirroring the model's path. Where sibling models each |
| 147 | + need only a model-level description, one file for the group is fine (see |
| 148 | + `models/docs/intermediate/trades/int_trade_agg.md`). |
| 149 | +- Open each file with a subject comment: `[comment]: < Trade Aggregations -`. Most files use |
| 150 | + exactly that form, dangling hyphen included; prefer it in new files. |
| 151 | +- Split `universal.md` into subject files (`asset.md`, `ledger_state.md`, `batch.md`) at around |
| 152 | + 60 to 80 blocks. Below that, one file is easier to search. |
| 153 | +- `models/docs/sources/history_operations.md` holds 125 blocks and is the one file that is |
| 154 | + genuinely too large. Splitting it is tracked separately. |
| 155 | + |
| 156 | +## Relationship to stellar-dbt |
| 157 | + |
| 158 | +`stellar-dbt` installs this repo as a git package and references roughly 200 doc blocks defined |
| 159 | +here. **Never rename or delete a block** without checking there first: a rename breaks its parse |
| 160 | +and costs a `packages.yml` pin bump plus `dbt deps` to unwind. Moving a block between files is |
| 161 | +safe. |
| 162 | + |
| 163 | +To check before merging something risky, point `stellar-dbt`'s `packages.yml` at your branch and |
| 164 | +run `dbt deps && dbt parse` there. A clean parse confirms every cross-repo reference resolves. |
| 165 | + |
| 166 | +`scripts/docs_lint.py` is meant to be identical in both repos: it reads |
| 167 | +the project name from `dbt_project.yml` rather than hardcoding it, so a change made here is |
| 168 | +copied across verbatim rather than ported by hand. |
0 commit comments