quickdbd-cli 0.1.7__tar.gz → 0.1.8__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 (56) hide show
  1. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/PKG-INFO +32 -5
  2. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/README.md +31 -4
  3. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/pyproject.toml +1 -1
  4. quickdbd_cli-0.1.8/src/quickdbd_cli/__init__.py +1 -0
  5. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/render.js +5 -4
  6. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/style.css +11 -0
  7. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/viewer.js +29 -2
  8. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/dbt.py +15 -1
  9. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/html.py +2 -1
  10. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/measure.py +10 -4
  11. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/model.py +3 -0
  12. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/parser.py +48 -1
  13. quickdbd_cli-0.1.8/src/quickdbd_cli/tags.py +95 -0
  14. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_select.py +3 -3
  15. quickdbd_cli-0.1.8/tests/test_tags.py +196 -0
  16. quickdbd_cli-0.1.7/src/quickdbd_cli/__init__.py +0 -1
  17. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/.gitignore +0 -0
  18. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/LICENSE +0 -0
  19. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/NOTICE +0 -0
  20. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/quickdbd-sample.txt +0 -0
  21. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/finance.txt +0 -0
  22. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/hr.txt +0 -0
  23. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/inventory.txt +0 -0
  24. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/marketing.txt +0 -0
  25. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/qdbd.layout.json +0 -0
  26. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/sales.txt +0 -0
  27. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/gen_font_metrics.py +0 -0
  28. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/gen_schema.py +0 -0
  29. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/release.sh +0 -0
  30. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/__main__.py +0 -0
  31. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  32. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  33. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  34. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  35. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  36. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/editor.js +0 -0
  37. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  38. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  39. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  40. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/cli.py +0 -0
  41. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/diff.py +0 -0
  42. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/export.py +0 -0
  43. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/jsrt.py +0 -0
  44. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/layout.py +0 -0
  45. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/project.py +0 -0
  46. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/serve.py +0 -0
  47. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/LICENSE +0 -0
  48. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/README.md +0 -0
  49. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/catalog.json +0 -0
  50. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/manifest.json +0 -0
  51. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_dbt.py +0 -0
  52. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_diff.py +0 -0
  53. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_parser.py +0 -0
  54. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_path.py +0 -0
  55. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_pipeline.py +0 -0
  56. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_serve.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
5
5
  Project-URL: Homepage, https://github.com/MarkPhamm/quickdbd-cli
6
6
  Author: Minh (Mark) Pham
@@ -202,7 +202,32 @@ Where the layout file goes:
202
202
 
203
203
  `qdbd build` never writes the layout file, so CI builds leave your working tree clean. More: [Table Positions](https://markphamm.github.io/quickdbd-cli/layout/).
204
204
 
205
- ### 3.4 Differences from QuickDBD
205
+ ### 3.4 Tags and colours
206
+
207
+ Give tables a role and a header colour. Tags are comments, so the file still pastes into QuickDBD.
208
+
209
+ ```text
210
+ # @color gold #b8860b # optional: pick a tag's colour
211
+
212
+ # @tag: gold, pii
213
+ customers
214
+ -
215
+ customer_id PK int
216
+
217
+ dim_date # tagged "dim" automatically from its name
218
+ -
219
+ date_key PK int
220
+ ```
221
+
222
+ A table's tags come from the first source that has any:
223
+
224
+ 1. an explicit `# @tag:` comment;
225
+ 2. its name: `dim_*` → dim, `fct_*` / `fact_*` → fact, `stg_*` → staging, `int_*` → intermediate, `raw_*` / `src_*` → raw;
226
+ 3. dbt tags, written as `# @dbt-tags:` by `qdbd import dbt`.
227
+
228
+ The header takes the colour of the first tag. In the page, a chip per tag under the filter box shows only that tag, and the selector `tag:fact` works everywhere. More: [Tags and Colours](https://markphamm.github.io/quickdbd-cli/tags/).
229
+
230
+ ### 3.5 Differences from QuickDBD
206
231
 
207
232
  `qdbd` accepts a few things QuickDBD rejects, and prints a warning for each:
208
233
 
@@ -243,6 +268,7 @@ The explorer's filter box takes dbt-style selectors that follow **relationships*
243
268
  | `+orders+1` | one hop in both directions |
244
269
  | `group:marts`, `file:core.txt` | a folder or a file |
245
270
  | `col:customer_id` | tables with a matching column |
271
+ | `tag:fact` | tables with a matching tag ([3.4](#34-tags-and-colours)) |
246
272
  | `a b` / `a,b` / `!a` | union / intersection / exclude |
247
273
 
248
274
  ```bash
@@ -288,6 +314,7 @@ See it on a real project: the [jaffle_shop demo](https://markphamm.github.io/qui
288
314
  | Relationships | `relationships` tests (incl. dbt_utils `relationships_where`) and `foreign_key` constraints |
289
315
  | Column types | `catalog.json` |
290
316
  | Tooltips | model and column descriptions |
317
+ | Header colours | dbt `tags` (unless the name or an explicit `# @tag` says otherwise) |
291
318
 
292
319
  Useful options: `--select 'marts.*'` (repeatable), `--no-sources`, `--package all`, `-o schema.txt` for a single file.
293
320
 
@@ -341,7 +368,7 @@ jobs:
341
368
  permissions: { contents: read, pull-requests: write }
342
369
  steps:
343
370
  - uses: actions/checkout@v4
344
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
371
+ - uses: MarkPhamm/quickdbd-cli@v0.1.8
345
372
  with:
346
373
  path: erd/ # schema file(s) or folder(s)
347
374
  output: site/index.html
@@ -364,7 +391,7 @@ Run your dbt setup first, then let the action generate the schema:
364
391
 
365
392
  ```yaml
366
393
  # … dbt deps && dbt build && dbt docs generate
367
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
394
+ - uses: MarkPhamm/quickdbd-cli@v0.1.8
368
395
  with:
369
396
  dbt-manifest: target/
370
397
  dbt-args: --infer-fks
@@ -379,7 +406,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
379
406
  ```yaml
380
407
  repos:
381
408
  - repo: https://github.com/MarkPhamm/quickdbd-cli
382
- rev: v0.1.7
409
+ rev: v0.1.8
383
410
  hooks:
384
411
  - id: qdbd-check
385
412
  files: ^erd/.*\.txt$
@@ -174,7 +174,32 @@ Where the layout file goes:
174
174
 
175
175
  `qdbd build` never writes the layout file, so CI builds leave your working tree clean. More: [Table Positions](https://markphamm.github.io/quickdbd-cli/layout/).
176
176
 
177
- ### 3.4 Differences from QuickDBD
177
+ ### 3.4 Tags and colours
178
+
179
+ Give tables a role and a header colour. Tags are comments, so the file still pastes into QuickDBD.
180
+
181
+ ```text
182
+ # @color gold #b8860b # optional: pick a tag's colour
183
+
184
+ # @tag: gold, pii
185
+ customers
186
+ -
187
+ customer_id PK int
188
+
189
+ dim_date # tagged "dim" automatically from its name
190
+ -
191
+ date_key PK int
192
+ ```
193
+
194
+ A table's tags come from the first source that has any:
195
+
196
+ 1. an explicit `# @tag:` comment;
197
+ 2. its name: `dim_*` → dim, `fct_*` / `fact_*` → fact, `stg_*` → staging, `int_*` → intermediate, `raw_*` / `src_*` → raw;
198
+ 3. dbt tags, written as `# @dbt-tags:` by `qdbd import dbt`.
199
+
200
+ The header takes the colour of the first tag. In the page, a chip per tag under the filter box shows only that tag, and the selector `tag:fact` works everywhere. More: [Tags and Colours](https://markphamm.github.io/quickdbd-cli/tags/).
201
+
202
+ ### 3.5 Differences from QuickDBD
178
203
 
179
204
  `qdbd` accepts a few things QuickDBD rejects, and prints a warning for each:
180
205
 
@@ -215,6 +240,7 @@ The explorer's filter box takes dbt-style selectors that follow **relationships*
215
240
  | `+orders+1` | one hop in both directions |
216
241
  | `group:marts`, `file:core.txt` | a folder or a file |
217
242
  | `col:customer_id` | tables with a matching column |
243
+ | `tag:fact` | tables with a matching tag ([3.4](#34-tags-and-colours)) |
218
244
  | `a b` / `a,b` / `!a` | union / intersection / exclude |
219
245
 
220
246
  ```bash
@@ -260,6 +286,7 @@ See it on a real project: the [jaffle_shop demo](https://markphamm.github.io/qui
260
286
  | Relationships | `relationships` tests (incl. dbt_utils `relationships_where`) and `foreign_key` constraints |
261
287
  | Column types | `catalog.json` |
262
288
  | Tooltips | model and column descriptions |
289
+ | Header colours | dbt `tags` (unless the name or an explicit `# @tag` says otherwise) |
263
290
 
264
291
  Useful options: `--select 'marts.*'` (repeatable), `--no-sources`, `--package all`, `-o schema.txt` for a single file.
265
292
 
@@ -313,7 +340,7 @@ jobs:
313
340
  permissions: { contents: read, pull-requests: write }
314
341
  steps:
315
342
  - uses: actions/checkout@v4
316
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
343
+ - uses: MarkPhamm/quickdbd-cli@v0.1.8
317
344
  with:
318
345
  path: erd/ # schema file(s) or folder(s)
319
346
  output: site/index.html
@@ -336,7 +363,7 @@ Run your dbt setup first, then let the action generate the schema:
336
363
 
337
364
  ```yaml
338
365
  # … dbt deps && dbt build && dbt docs generate
339
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
366
+ - uses: MarkPhamm/quickdbd-cli@v0.1.8
340
367
  with:
341
368
  dbt-manifest: target/
342
369
  dbt-args: --infer-fks
@@ -351,7 +378,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
351
378
  ```yaml
352
379
  repos:
353
380
  - repo: https://github.com/MarkPhamm/quickdbd-cli
354
- rev: v0.1.7
381
+ rev: v0.1.8
355
382
  hooks:
356
383
  - id: qdbd-check
357
384
  files: ^erd/.*\.txt$
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quickdbd-cli"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.8"
@@ -78,7 +78,7 @@
78
78
  var st = model.style, w = g.w, h = g.h, out = [];
79
79
  out.push('<g class="t" data-id="' + esc(t.id) + '" transform="translate(' + n(p[0]) + "," + n(p[1]) + ')">');
80
80
  out.push('<rect class="tb" width="' + w + '" height="' + h + '" rx="2" fill="' + C.body + '" stroke="' + C.border + '"/>');
81
- var state = t.diff && t.diff.state, hcol = state === "added" ? C.added : state === "changed" ? C.changed : C.header;
81
+ var state = t.diff && t.diff.state, hcol = state === "added" ? C.added : state === "changed" ? C.changed : t.color || C.header;
82
82
  out.push('<path class="th" d="M0 2a2 2 0 0 1 2-2h' + (w - 4) + "a2 2 0 0 1 2 2v" + (st.headerH - 2) + "h-" + w + 'z" fill="' + hcol + '"/>');
83
83
  if (state) {
84
84
  out.push('<text x="' + (w - st.padX) + '" y="' + n(st.headerH / 2 + 4) + '" text-anchor="end" fill="#fff" font-family="' + FONT +
@@ -310,7 +310,7 @@
310
310
  cells.push(cell({
311
311
  id: tid, value: t.name, vertex: "1", parent: "1",
312
312
  style: "shape=table;startSize=" + HEADH + ";container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;" +
313
- "fontStyle=1;align=left;spacingLeft=10;resizeLast=1;html=0;whiteSpace=wrap;fillColor=#2b5797;fontColor=#ffffff;" +
313
+ "fontStyle=1;align=left;spacingLeft=10;resizeLast=1;html=0;whiteSpace=wrap;fillColor=" + (t.color || C.header) + ";fontColor=#ffffff;" +
314
314
  "strokeColor=#dfe3e8;swimlaneFillColor=#ffffff;fontSize=14;",
315
315
  }, g(p[0] - b[0] + 40, p[1] - b[1] + 40, w, HEADH + Math.max(tg.cols.length, 0) * ROWH)));
316
316
  tg.cols.forEach(function (c, i) {
@@ -350,7 +350,7 @@
350
350
  /* ------------------------------------------------------------------ selectors (dbt-style, over relationships)
351
351
  * Space-separated atoms are unioned; a comma inside an atom intersects (a,b); a leading ! excludes.
352
352
  * Atom: [N]+ method:pattern +[N]
353
- * method: name (default; also matches aliases), group, file, col
353
+ * method: name (default; also matches aliases), group, file, col, tag
354
354
  * pattern: glob with * and ?
355
355
  * +x x plus the tables it references (parents), any depth; 2+x limits to 2 hops
356
356
  * x+ x plus the tables referencing it (children); x+1 limits to 1 hop
@@ -391,11 +391,12 @@
391
391
  else if (method === "group") ok = re.test(t.group || "") || (t.group || "").toLowerCase().indexOf(pat.toLowerCase() + "/") === 0;
392
392
  else if (method === "file") ok = re.test(t.file || "") || re.test((t.file || "").split("/").pop());
393
393
  else if (method === "col" || method === "column") ok = t.cols.some(function (c) { return re.test(c.name); });
394
+ else if (method === "tag") ok = (t.tags || []).some(function (x) { return re.test(x); });
394
395
  else if (method === "diff" || method === "state") {
395
396
  var ds = t.diff && t.diff.state, want = pat.toLowerCase();
396
397
  ok = !!ds && (want === "modified" || want === "*" || want === ds || (want === "new" && ds === "added"));
397
398
  }
398
- else throw new Error("unknown selector method '" + method + "' (use name, group, file, col, diff)");
399
+ else throw new Error("unknown selector method '" + method + "' (use name, group, file, col, tag, diff)");
399
400
  if (ok) { out[t.id] = true; base.push(t.id); }
400
401
  });
401
402
  function hops(op, before) {
@@ -56,6 +56,17 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
56
56
  .fstat { font-size: 11px; color: var(--muted); padding: 3px 2px 0; min-height: 14px; }
57
57
  .fstat.bad { color: #c0392b; }
58
58
  .fstat a { color: var(--blue); cursor: pointer; margin-left: 6px; }
59
+ .tagbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 0 2px; }
60
+ .tchip { display: inline-flex; align-items: center; gap: 4px; font: 11px/1.6 inherit; padding: 0 7px 0 5px; border: 1px solid var(--line);
61
+ border-radius: 10px; background: #fff; color: var(--text, #333); cursor: pointer; }
62
+ .tchip i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
63
+ .tchip small { color: var(--muted); }
64
+ .tchip:hover { border-color: #b8c2d0; }
65
+ .tchip.on { border-color: var(--blue); background: #eef3fa; }
66
+ .tdot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin: 0 6px 1px -13px; vertical-align: middle; }
67
+ .tree.flat .tdot { margin-left: 0; }
68
+ .details .ttags { margin: -4px 0 10px; display: flex; gap: 4px; flex-wrap: wrap; }
69
+ .details .ttag { color: #fff; font-size: 11px; padding: 1px 8px; border-radius: 10px; cursor: pointer; text-decoration: none; }
59
70
  .tree { overflow: auto; padding: 4px 0 8px; }
60
71
  .explorer.collapsed .tree { display: none; }
61
72
  .gh { padding: 6px 12px 4px; font-size: 11px; font-weight: bold; color: var(--muted); text-transform: uppercase;
@@ -331,7 +331,8 @@
331
331
  var open = q || !shut[g];
332
332
  var rows = items.map(function (t) {
333
333
  if (open) visible.push(t.id);
334
- return '<div class="ti" data-id="' + Q.esc(t.id) + '">' + Q.esc(t.name) +
334
+ return '<div class="ti" data-id="' + Q.esc(t.id) + '">' +
335
+ (t.color ? '<i class="tdot" style="background:' + Q.esc(t.color) + '" title="' + Q.esc(t.tags.join(", ")) + '"></i>' : "") + Q.esc(t.name) +
335
336
  (t.aliases.length ? "<small>as " + Q.esc(t.aliases.join(", ")) + "</small>" : "") + "</div>";
336
337
  }).join("");
337
338
  if (flat) return rows;
@@ -408,7 +409,25 @@
408
409
  if (err) fstat.textContent = err;
409
410
  else if (ids) fstat.innerHTML = "Showing " + ids.length + " of " + S.model.tables.length + ' tables<a id="fclear">clear</a>';
410
411
  else fstat.textContent = "";
412
+ drawTags();
413
+ }
414
+ /* Tag legend: one chip per tag (header colour + count); a click filters to tag:NAME, a second click clears. */
415
+ var tagbar = document.getElementById("tagbar");
416
+ function drawTags() {
417
+ var tags = S.model.tags || [];
418
+ tagbar.style.display = tags.length ? "" : "none";
419
+ tagbar.innerHTML = tags.map(function (g) {
420
+ var on = S.filter.toLowerCase() === "tag:" + g.name;
421
+ return '<button class="tchip' + (on ? " on" : "") + '" data-tag="' + Q.esc(g.name) + '" title="Show only tag:' + Q.esc(g.name) +
422
+ '"><i style="background:' + Q.esc(g.color) + '"></i>' + Q.esc(g.name) + "<small>" + g.count + "</small></button>";
423
+ }).join("");
411
424
  }
425
+ tagbar.addEventListener("click", function (ev) {
426
+ var b = ev.target.closest(".tchip");
427
+ if (!b) return;
428
+ var expr = "tag:" + b.getAttribute("data-tag");
429
+ applyFilter(S.filter.toLowerCase() === expr ? "" : expr);
430
+ });
412
431
  function applyFilter(expr, keepView) {
413
432
  S.filter = (expr || "").trim();
414
433
  if (finput.value.trim() !== S.filter) finput.value = S.filter;
@@ -476,6 +495,12 @@
476
495
  if (t.aliases.length) meta.push("as " + Q.esc(t.aliases.join(", ")));
477
496
  if (t.file) meta.push(Q.esc(t.file) + ":" + t.line);
478
497
  if (meta.length) out.push('<div class="meta">' + meta.join(" &middot; ") + "</div>");
498
+ if (t.tags) {
499
+ out.push('<div class="ttags">' + t.tags.map(function (g) {
500
+ var c = (S.model.tags || []).filter(function (x) { return x.name === g; })[0];
501
+ return '<a class="ttag" data-tag="' + Q.esc(g) + '" style="background:' + Q.esc(c ? c.color : "#2b5797") + '">' + Q.esc(g) + "</a>";
502
+ }).join("") + "</div>");
503
+ }
479
504
  if (t.doc) out.push('<div class="doc">' + Q.esc(t.doc) + "</div>");
480
505
  out.push('<div class="pathbox"><input id="pathto" list="pathlist" placeholder="Path to another table…" autocomplete="off" value="' +
481
506
  (S.path && byId[S.path.to] ? Q.esc(byId[S.path.to].name) : "") + '"></div><datalist id="pathlist">' +
@@ -558,6 +583,8 @@
558
583
  dbody.addEventListener("click", function (ev) {
559
584
  var a = ev.target.closest("a[data-go]");
560
585
  if (a) select(a.getAttribute("data-go"), { center: true });
586
+ var tg = ev.target.closest("a.ttag");
587
+ if (tg) applyFilter("tag:" + tg.getAttribute("data-tag"));
561
588
  });
562
589
  document.getElementById("dclose").onclick = function () { select(null); };
563
590
 
@@ -570,7 +597,7 @@
570
597
  var geo = Q.geometry(S.vm, S.mode), parts = [];
571
598
  S.vm.tables.forEach(function (t) {
572
599
  var p = S.pos[t.id];
573
- if (p) parts.push('<rect x="' + p[0] + '" y="' + p[1] + '" width="' + geo[t.id].w + '" height="' + geo[t.id].h + '" fill="#2b5797" opacity=".55"/>');
600
+ if (p) parts.push('<rect x="' + p[0] + '" y="' + p[1] + '" width="' + geo[t.id].w + '" height="' + geo[t.id].h + '" fill="' + (t.color || "#2b5797") + '" opacity=".55"/>');
574
601
  });
575
602
  parts.push('<rect class="vpr" id="vpr"/>');
576
603
  mini.innerHTML = parts.join("");
@@ -113,6 +113,7 @@ class Tbl:
113
113
  group: str
114
114
  description: str = ""
115
115
  cols: dict[str, Col] = field(default_factory=dict)
116
+ tags: list[str] = field(default_factory=list)
116
117
 
117
118
  def col(self, name: str) -> Col:
118
119
  key = name.lower()
@@ -173,6 +174,17 @@ def _comment_lines(text: str) -> list[str]:
173
174
  return out
174
175
 
175
176
 
177
+ def _node_tags(n: dict) -> list[str]:
178
+ """dbt tags (node-level and config), lowercased, in order, usable as qdbd tag names."""
179
+ out: list[str] = []
180
+ raw = list(n.get("tags") or []) + list((n.get("config") or {}).get("tags") or [])
181
+ for tag in raw:
182
+ tag = re.sub(r"[^a-z0-9_.-]+", "-", str(tag).lower()).strip("-.")
183
+ if tag and tag not in out:
184
+ out.append(tag)
185
+ return out
186
+
187
+
176
188
  class _Importer:
177
189
  def __init__(
178
190
  self,
@@ -231,7 +243,7 @@ class _Importer:
231
243
  name = self._base_name(n)
232
244
  if names[name] > 1: # same name in two packages
233
245
  name = f"{n.get('package_name')}.{name}"
234
- t = Tbl(n["unique_id"], name, self._group(n), n.get("description") or "")
246
+ t = Tbl(n["unique_id"], name, self._group(n), n.get("description") or "", tags=_node_tags(n))
235
247
  self._columns(t, n)
236
248
  self.tables[t.uid] = t
237
249
  self.by_name.setdefault(n.get("name", "").lower(), []).append(t.uid)
@@ -435,6 +447,8 @@ class _Importer:
435
447
  # -- output -------------------------------------------------------------
436
448
  def render_table(self, t: Tbl) -> list[str]:
437
449
  lines = _comment_lines(t.description)
450
+ if t.tags:
451
+ lines.append(f"# @dbt-tags: {', '.join(t.tags)}")
438
452
  lines += [quote(t.name), "-"]
439
453
  for c in sorted(t.cols.values(), key=lambda c: (c.order, c.name.lower())):
440
454
  lines += _comment_lines(c.description)
@@ -37,6 +37,7 @@ BODY = """
37
37
  <input id="filter" type="text" placeholder="Filter: orders+1 group:marts !stg_*" autocomplete="off"
38
38
  spellcheck="false" title="{filter_help}">
39
39
  <div class="fstat" id="fstat"></div>
40
+ <div class="tagbar" id="tagbar"></div>
40
41
  </div>
41
42
  <div id="tree" class="tree"></div>
42
43
  </div>
@@ -87,7 +88,7 @@ SIDE = """
87
88
 
88
89
  FILTER_HELP = (
89
90
  "dbt-style selectors. +x: x and what it references. x+: x and what references it. "
90
- "N+x+N: hops. group:/file:/col: methods. a,b: both. !x: exclude."
91
+ "N+x+N: hops. group:/file:/col:/tag: methods. a,b: both. !x: exclude."
91
92
  )
92
93
 
93
94
 
@@ -11,6 +11,7 @@ from functools import cache
11
11
  from importlib import resources
12
12
  from pathlib import PurePosixPath
13
13
 
14
+ from . import tags
14
15
  from .model import Column, Schema, Table
15
16
 
16
17
  STYLE = {
@@ -127,12 +128,17 @@ def model_json(schema: Schema) -> dict:
127
128
  "b": {"t": r.b.table, "c": r.b.column, "card": r.b.card},
128
129
  }
129
130
  )
130
- return {
131
+ tables = [
132
+ {**table_json(t), "group": groups[t.path], "file": t.path, "line": t.line} for t in schema.tables.values()
133
+ ]
134
+ legend = tags.annotate(schema, tables)
135
+ out = {
131
136
  "style": STYLE,
132
- "tables": [
133
- {**table_json(t), "group": groups[t.path], "file": t.path, "line": t.line} for t in schema.tables.values()
134
- ],
137
+ "tables": tables,
135
138
  "rels": rels,
136
139
  "zoom": schema.zoom,
137
140
  "position": list(schema.position) if schema.position else None,
138
141
  }
142
+ if legend:
143
+ out["tags"] = legend
144
+ return out
@@ -58,6 +58,8 @@ class Table:
58
58
  aliases: list[str] = field(default_factory=list)
59
59
  columns: dict[str, Column] = field(default_factory=dict)
60
60
  comments: list[str] = field(default_factory=list)
61
+ tags: list[str] = field(default_factory=list) # `# @tag: ...` comments
62
+ dbt_tags: list[str] = field(default_factory=list) # `# @dbt-tags: ...`, written by `qdbd import dbt`
61
63
 
62
64
 
63
65
  @dataclass
@@ -84,6 +86,7 @@ class Schema:
84
86
  layout: dict[str, tuple[float, float]] = field(default_factory=dict)
85
87
  diagnostics: list[Diagnostic] = field(default_factory=list)
86
88
  general_comments: list[str] = field(default_factory=list)
89
+ tag_colors: dict[str, str] = field(default_factory=dict) # `# @color TAG #hex`
87
90
 
88
91
  @property
89
92
  def errors(self) -> list[Diagnostic]:
@@ -22,6 +22,21 @@ SECTION_ALIASES = {"rels": "relationships"}
22
22
  ALLOWED_REL = ", ".join(REL_TYPES)
23
23
  REJECT = "QuickDBD would reject this: "
24
24
 
25
+ # qdbd directives live in comments, so files stay valid QuickDBD.
26
+ TAG_RE = re.compile(r"^@(tags?|dbt-tags?)\s*:?\s*(.*)$", re.I)
27
+ COLOR_RE = re.compile(r"^@colou?r\b\s*:?\s*(.*)$", re.I)
28
+ TAG_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
29
+ HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")
30
+
31
+
32
+ def split_tags(text: str) -> list[str]:
33
+ out: list[str] = []
34
+ for t in re.split(r"[\s,]+", text.strip()):
35
+ t = t.lower()
36
+ if t and t not in out:
37
+ out.append(t)
38
+ return out
39
+
25
40
 
26
41
  # --------------------------------------------------------------------------- helpers
27
42
 
@@ -197,6 +212,7 @@ class FileParse:
197
212
  layout_entries: list[Entry] = field(default_factory=list)
198
213
  diagnostics: list[Diagnostic] = field(default_factory=list)
199
214
  general_comments: list[str] = field(default_factory=list)
215
+ colors: dict[str, tuple[str, int]] = field(default_factory=dict) # tag -> (#hex, line)
200
216
 
201
217
  def error(self, line: int, msg: str) -> None:
202
218
  self.diagnostics.append(Diagnostic(self.path, line, msg, "error"))
@@ -227,6 +243,11 @@ class _FileParser:
227
243
  raw = raw.replace("\t", " ")
228
244
  blank = raw.rstrip() == ""
229
245
  data, comment = line_data(raw)
246
+ if comment and COLOR_RE.match(comment):
247
+ # the hex value has its own '#', which line_data treats as the end of the comment
248
+ full = re.search(r"#\s*(@colou?r\b.*)$", raw, re.I)
249
+ self._color(full.group(1).strip() if full else comment, lineno)
250
+ comment = None
230
251
  if comment:
231
252
  pending.append(comment)
232
253
  elif blank and not seen_data:
@@ -250,6 +271,17 @@ class _FileParser:
250
271
  out[section].append(entry)
251
272
  return out
252
273
 
274
+ def _color(self, comment: str, line: int) -> None:
275
+ parts = COLOR_RE.match(comment).group(1).split()
276
+ if len(parts) > 2 and parts[2].startswith("#"): # a trailing comment after the colour
277
+ parts = parts[:2]
278
+ if len(parts) != 2 or not TAG_NAME_RE.match(parts[0]) or not HEX_RE.match(parts[1]):
279
+ self.fp.diagnostics.append(
280
+ Diagnostic(self.fp.path, line, "Invalid @color directive (use '# @color TAG #rrggbb')", "warning")
281
+ )
282
+ return
283
+ self.fp.colors[parts[0].lower()] = (parts[1].lower(), line)
284
+
253
285
  def _validate_section(self, name: str, seen: list[str], line: int) -> bool:
254
286
  if name not in SECTIONS:
255
287
  self.fp.error(line, f"Invalid section name '[{name}]' (allowed: {', '.join(SECTIONS)})")
@@ -369,7 +401,16 @@ class _FileParser:
369
401
  if ident in fp.tables:
370
402
  fp.error(e.line, f"Table '{caption}' already defined")
371
403
  return None
372
- fp.tables[ident] = Table(ident, caption, fp.path, e.line, aliases, {}, list(e.comments))
404
+ table = Table(ident, caption, fp.path, e.line, aliases, {})
405
+ for c in e.comments:
406
+ m = TAG_RE.match(c)
407
+ if not m:
408
+ table.comments.append(c)
409
+ elif m.group(1).lower().startswith("dbt"):
410
+ table.dbt_tags += [t for t in split_tags(m.group(2)) if t not in table.dbt_tags]
411
+ else:
412
+ table.tags += [t for t in split_tags(m.group(2)) if t not in table.tags]
413
+ fp.tables[ident] = table
373
414
  for a in aliases:
374
415
  fp.aliases[a] = ident
375
416
  return ident
@@ -569,6 +610,12 @@ class _Resolver:
569
610
  s.tables[ident] = table
570
611
  self.aliases.update(f.aliases)
571
612
  s.general_comments.extend(f.general_comments)
613
+ for tag, (color, line) in sorted(f.colors.items()):
614
+ if tag not in s.tag_colors:
615
+ s.tag_colors[tag] = color
616
+ elif s.tag_colors[tag] != color:
617
+ msg = f"@color for '{tag}' differs from an earlier file; using the first"
618
+ self.error(f.path, line, msg, "warning")
572
619
  zoom = f.settings.get("zoom")
573
620
  pos = f.settings.get("position")
574
621
  lines = f.settings.get("_lines", {})
@@ -0,0 +1,95 @@
1
+ """Table tags and their header colours.
2
+
3
+ A table's tags come from the first source that has any:
4
+
5
+ 1. explicit ``# @tag: dim, pii`` comments;
6
+ 2. its name: ``dim_*`` / ``DimCustomer`` -> dim, ``fct_*`` -> fact, ``stg_*`` -> staging, ...;
7
+ 3. ``# @dbt-tags: ...`` comments written by ``qdbd import dbt``.
8
+
9
+ The header takes the colour of the table's first tag. ``# @color TAG #hex`` overrides the defaults.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .model import Schema, Table
15
+
16
+ # tag -> name prefixes (matched as `prefix_...` or `PrefixName`)
17
+ NAME_TAGS: list[tuple[str, tuple[str, ...]]] = [
18
+ ("dim", ("dim",)),
19
+ ("fact", ("fct", "fact")),
20
+ ("staging", ("stg", "staging")),
21
+ ("intermediate", ("int",)),
22
+ ("raw", ("raw", "src")),
23
+ ]
24
+
25
+ # Defaults avoid the diff colours (green = added, amber = changed) and the untagged blue.
26
+ DEFAULT_COLORS = {
27
+ "dim": "#00838f",
28
+ "fact": "#c2185b",
29
+ "staging": "#546e7a",
30
+ "intermediate": "#7b1fa2",
31
+ "raw": "#6d4c41",
32
+ }
33
+ PALETTE = ["#0277bd", "#4527a0", "#00695c", "#ad1457", "#827717", "#bf360c", "#37474f", "#283593", "#5d4037"]
34
+
35
+
36
+ def _name_parts(name: str) -> list[str]:
37
+ """The name, plus the part after a source qualifier (`src.tbl`) or a dbt `<layer>_<source>__<entity>`."""
38
+ out = [name]
39
+ for sep in (".", "__"):
40
+ if sep in name:
41
+ out.append(name.rsplit(sep, 1)[1])
42
+ return out
43
+
44
+
45
+ def name_tag(name: str) -> str | None:
46
+ for part in _name_parts(name):
47
+ low = part.lower()
48
+ for tag, prefixes in NAME_TAGS:
49
+ for p in prefixes:
50
+ if low.startswith(p + "_") and len(low) > len(p) + 1:
51
+ return tag
52
+ rest = part[len(p) :]
53
+ if part[: len(p)] == p.capitalize() and rest[:1].isupper():
54
+ return tag
55
+ return None
56
+
57
+
58
+ def table_tags(t: Table) -> list[str]:
59
+ if t.tags:
60
+ return list(t.tags)
61
+ tag = name_tag(t.name)
62
+ if tag:
63
+ return [tag]
64
+ return list(t.dbt_tags)
65
+
66
+
67
+ def tag_colors(schema: Schema, tags_by_table: dict[str, list[str]]) -> dict[str, str]:
68
+ """Colour of every tag in use: explicit @color, else the default, else the next palette colour (by name)."""
69
+ used = sorted({tag for tags in tags_by_table.values() for tag in tags})
70
+ out: dict[str, str] = {}
71
+ i = 0
72
+ for tag in used:
73
+ if tag in schema.tag_colors:
74
+ out[tag] = schema.tag_colors[tag]
75
+ elif tag in DEFAULT_COLORS:
76
+ out[tag] = DEFAULT_COLORS[tag]
77
+ else:
78
+ out[tag] = PALETTE[i % len(PALETTE)]
79
+ i += 1
80
+ return out
81
+
82
+
83
+ def annotate(schema: Schema, tables: list[dict]) -> list[dict]:
84
+ """Add ``tags`` and ``color`` to each table dict of the model JSON; return the legend."""
85
+ by_id = {t["id"]: table_tags(schema.tables[t["id"]]) for t in tables}
86
+ colors = tag_colors(schema, by_id)
87
+ counts: dict[str, int] = {}
88
+ for t in tables:
89
+ tags = by_id[t["id"]]
90
+ if tags:
91
+ t["tags"] = tags
92
+ t["color"] = colors[tags[0]]
93
+ for tag in tags:
94
+ counts[tag] = counts.get(tag, 0) + 1
95
+ return [{"name": k, "color": colors[k], "count": counts[k]} for k in sorted(counts, key=lambda k: (-counts[k], k))]
@@ -93,7 +93,7 @@ def test_selectors(model, expr, expected):
93
93
 
94
94
  def test_unknown_method(model):
95
95
  with pytest.raises(Exception, match="unknown selector method"):
96
- sel(model, "tag:pii")
96
+ sel(model, "owner:pii")
97
97
 
98
98
 
99
99
  # ------------------------------------------------------------------ CLI --select / --exclude
@@ -132,8 +132,8 @@ def test_select_errors(tmp_path, capsys):
132
132
  root = write_files(tmp_path)
133
133
  assert main(["build", str(root), "-s", "nope", "-o", str(tmp_path / "d.html")]) == 1
134
134
  assert "no tables match 'nope'" in capsys.readouterr().err
135
- assert main(["build", str(root), "-s", "tag:x", "-o", str(tmp_path / "d.html")]) == 1
136
- assert "error: unknown selector method 'tag'" in capsys.readouterr().err
135
+ assert main(["build", str(root), "-s", "owner:x", "-o", str(tmp_path / "d.html")]) == 1
136
+ assert "error: unknown selector method 'owner'" in capsys.readouterr().err
137
137
 
138
138
 
139
139
  def test_selector_expr():
@@ -0,0 +1,196 @@
1
+ """Table tags (`# @tag`, name prefixes, `# @dbt-tags`) and their header colours."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from quickdbd_cli.dbt import import_dbt
8
+ from quickdbd_cli.diff import annotate_model, diff_schemas
9
+ from quickdbd_cli.export import render_drawio, render_svg
10
+ from quickdbd_cli.jsrt import runtime
11
+ from quickdbd_cli.measure import model_json
12
+ from quickdbd_cli.parser import compile_sources, compile_text
13
+ from quickdbd_cli.tags import DEFAULT_COLORS, PALETTE, name_tag
14
+
15
+ SCHEMA = """# @color fact #123456
16
+ dim_customer
17
+ -
18
+ id PK int
19
+
20
+ # Orders, one per checkout
21
+ # @tag: gold, PII
22
+ fct_orders
23
+ -
24
+ id PK int
25
+ customer_id int FK >- dim_customer.id
26
+
27
+ Thing # @dbt-tags: nightly
28
+ -
29
+ id int
30
+
31
+ stg_shop__payments # @dbt-tags: nightly
32
+ -
33
+ id int
34
+
35
+ Plain
36
+ -
37
+ id int
38
+ """
39
+
40
+
41
+ def tables(model):
42
+ return {t["name"]: t for t in model["tables"]}
43
+
44
+
45
+ @pytest.fixture(scope="module")
46
+ def model():
47
+ return model_json(compile_text(SCHEMA))
48
+
49
+
50
+ def test_directives_are_parsed_and_kept_out_of_docs():
51
+ s = compile_text(SCHEMA)
52
+ t = s.tables["fct_orders"]
53
+ assert t.tags == ["gold", "pii"]
54
+ assert t.comments == ["Orders, one per checkout"]
55
+ assert s.tables["Thing"].dbt_tags == ["nightly"]
56
+ assert s.tables["Thing"].comments == []
57
+ assert s.tag_colors == {"fact": "#123456"}
58
+ assert s.diagnostics == []
59
+
60
+
61
+ def test_precedence_explicit_then_name_then_dbt(model):
62
+ t = tables(model)
63
+ assert t["fct_orders"]["tags"] == ["gold", "pii"] # explicit beats the fct_ prefix
64
+ assert t["dim_customer"]["tags"] == ["dim"] # name
65
+ assert t["stg_shop__payments"]["tags"] == ["staging"] # name beats dbt tags
66
+ assert t["Thing"]["tags"] == ["nightly"] # dbt tags
67
+ assert "tags" not in t["Plain"] and "color" not in t["Plain"]
68
+
69
+
70
+ def test_colours_and_legend(model):
71
+ t = tables(model)
72
+ assert t["dim_customer"]["color"] == DEFAULT_COLORS["dim"]
73
+ assert t["fct_orders"]["color"] == PALETTE[0] # "gold": first non-default tag, by name
74
+ legend = {g["name"]: (g["color"], g["count"]) for g in model["tags"]}
75
+ assert legend == {
76
+ "dim": (DEFAULT_COLORS["dim"], 1),
77
+ "gold": (PALETTE[0], 1),
78
+ "nightly": (PALETTE[1], 1),
79
+ "pii": (PALETTE[2], 1),
80
+ "staging": (DEFAULT_COLORS["staging"], 1),
81
+ }
82
+ assert "fact" not in legend # @color for an unused tag doesn't add it
83
+
84
+
85
+ def test_color_with_trailing_comment():
86
+ s = compile_text("# @color gold #b8860b # optional: pick a colour\nA\n-\nid int\n")
87
+ assert s.tag_colors == {"gold": "#b8860b"} and s.diagnostics == []
88
+
89
+
90
+ def test_color_override():
91
+ m = model_json(compile_text("# @color dim #ABCDEF\ndim_x\n-\nid int\n"))
92
+ assert m["tables"][0]["color"] == "#abcdef"
93
+
94
+
95
+ def test_no_tags_no_legend():
96
+ assert "tags" not in model_json(compile_text("A\n-\nid int\n"))
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ "line",
101
+ ["# @color dim", "# @color dim red", "# @color #fff dim", "# @color dim #12345"],
102
+ )
103
+ def test_bad_color_is_a_warning(line):
104
+ s = compile_text(f"{line}\nA\n-\nid int\n")
105
+ assert [d.severity for d in s.diagnostics] == ["warning"]
106
+ assert "Invalid @color" in s.diagnostics[0].message
107
+
108
+
109
+ def test_conflicting_colors_across_files():
110
+ a = ("a.txt", "# @color dim #111111\nA\n-\nid int\n")
111
+ b = ("b.txt", "# @color dim #222222\nB\n-\nid int\n")
112
+ s = compile_sources([a, b])
113
+ assert s.tag_colors == {"dim": "#111111"}
114
+ assert [(d.path, d.severity) for d in s.diagnostics] == [("b.txt", "warning")]
115
+
116
+
117
+ @pytest.mark.parametrize(
118
+ "name,tag",
119
+ [
120
+ ("dim_customer", "dim"),
121
+ ("DimCustomer", "dim"),
122
+ ("Dimension", None),
123
+ ("fct_orders", "fact"),
124
+ ("fact_sales", "fact"),
125
+ ("FactSales", "fact"),
126
+ ("Factory", None),
127
+ ("stg_orders", "staging"),
128
+ ("stg_shop__orders", "staging"),
129
+ ("conformed__dim_airline", "dim"),
130
+ ("int_orders_pivoted", "intermediate"),
131
+ ("integer_table", None),
132
+ ("raw_orders", "raw"),
133
+ ("jaffle_shop.raw_orders", "raw"),
134
+ ("dim_", None),
135
+ ("Customer", None),
136
+ ],
137
+ )
138
+ def test_name_tags(name, tag):
139
+ assert name_tag(name) == tag
140
+
141
+
142
+ def test_tag_selector(model):
143
+ rt = runtime()
144
+ rt.load("render.js")
145
+ assert rt.call("qdbdSelect", model, "tag:dim") == ["dim_customer"]
146
+ assert rt.call("qdbdSelect", model, "tag:pii") == ["fct_orders"]
147
+ assert rt.call("qdbdSelect", model, "tag:dim+") == ["dim_customer", "fct_orders"]
148
+ assert rt.call("qdbdSelect", model, "tag:nope") == []
149
+
150
+
151
+ def test_svg_and_drawio_use_the_tag_colour(model):
152
+ pos = {t["id"]: [i * 300, 0] for i, t in enumerate(model["tables"])}
153
+ svg = render_svg(model, pos, embed_font=False)
154
+ assert DEFAULT_COLORS["dim"] in svg and PALETTE[0] in svg and "#2b5797" in svg # Plain stays blue
155
+ xml = render_drawio(model, pos)
156
+ assert "fillColor=" + DEFAULT_COLORS["dim"] in xml
157
+
158
+
159
+ def test_diff_colour_wins_over_tag():
160
+ old = compile_text("A\n-\nid int\n")
161
+ new = compile_text("A\n-\nid int\n\ndim_b\n-\nid int\n")
162
+ m = annotate_model(model_json(new), diff_schemas(old, new), "main")
163
+ b = next(t for t in m["tables"] if t["name"] == "dim_b")
164
+ svg = render_svg(m, {"A": [0, 0], "dim_b": [300, 0]}, only=["dim_b"], embed_font=False)
165
+ assert b["color"] == DEFAULT_COLORS["dim"] and "#2e7d32" in svg and DEFAULT_COLORS["dim"] not in svg
166
+
167
+
168
+ def test_dbt_import_writes_dbt_tags(tmp_path):
169
+ def node(uid, name, tags=None, config_tags=None):
170
+ return {
171
+ "unique_id": uid,
172
+ "resource_type": "model",
173
+ "name": name,
174
+ "package_name": "shop",
175
+ "fqn": ["shop", name],
176
+ "original_file_path": f"models/{name}.sql",
177
+ "description": "",
178
+ "columns": {"id": {"name": "id", "description": "", "data_type": "int"}},
179
+ "tags": tags or [],
180
+ "config": {"tags": config_tags or []},
181
+ }
182
+
183
+ nodes = {
184
+ "model.shop.orders": node("model.shop.orders", "orders", ["Nightly", "finance"], ["finance"]),
185
+ "model.shop.dim_users": node("model.shop.dim_users", "dim_users", ["nightly"]),
186
+ "model.shop.misc": node("model.shop.misc", "misc"),
187
+ }
188
+ mf = tmp_path / "manifest.json"
189
+ mf.write_text(json.dumps({"metadata": {"project_name": "shop"}, "nodes": nodes, "sources": {}}))
190
+ res = import_dbt(mf)
191
+ text = "".join(res.files.values())
192
+ assert "# @dbt-tags: nightly, finance\norders\n" in text
193
+ m = tables(model_json(compile_sources(list(res.files.items()))))
194
+ assert m["orders"]["tags"] == ["nightly", "finance"]
195
+ assert m["dim_users"]["tags"] == ["dim"] # name beats dbt tags
196
+ assert "tags" not in m["misc"]
@@ -1 +0,0 @@
1
- __version__ = "0.1.7"
File without changes
File without changes
File without changes