quickdbd-cli 0.1.7__tar.gz → 0.1.9__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 (58) hide show
  1. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/PKG-INFO +54 -7
  2. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/README.md +53 -6
  3. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/pyproject.toml +1 -1
  4. quickdbd_cli-0.1.9/src/quickdbd_cli/__init__.py +1 -0
  5. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/render.js +5 -4
  6. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/style.css +11 -0
  7. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/viewer.js +29 -2
  8. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/cli.py +50 -3
  9. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/dbt.py +15 -1
  10. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/html.py +24 -7
  11. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/measure.py +10 -4
  12. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/model.py +3 -0
  13. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/parser.py +48 -1
  14. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/serve.py +27 -11
  15. quickdbd_cli-0.1.9/src/quickdbd_cli/share.py +109 -0
  16. quickdbd_cli-0.1.9/src/quickdbd_cli/tags.py +95 -0
  17. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_select.py +3 -3
  18. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_serve.py +36 -0
  19. quickdbd_cli-0.1.9/tests/test_share.py +116 -0
  20. quickdbd_cli-0.1.9/tests/test_tags.py +196 -0
  21. quickdbd_cli-0.1.7/src/quickdbd_cli/__init__.py +0 -1
  22. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/.gitignore +0 -0
  23. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/LICENSE +0 -0
  24. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/NOTICE +0 -0
  25. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/quickdbd-sample.txt +0 -0
  26. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/finance.txt +0 -0
  27. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/hr.txt +0 -0
  28. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/inventory.txt +0 -0
  29. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/marketing.txt +0 -0
  30. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/qdbd.layout.json +0 -0
  31. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/examples/synthetic/sales.txt +0 -0
  32. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/scripts/gen_font_metrics.py +0 -0
  33. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/scripts/gen_schema.py +0 -0
  34. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/scripts/release.sh +0 -0
  35. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/__main__.py +0 -0
  36. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  37. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  38. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  39. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  40. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  41. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/editor.js +0 -0
  42. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  43. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  44. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  45. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/diff.py +0 -0
  46. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/export.py +0 -0
  47. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/jsrt.py +0 -0
  48. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/layout.py +0 -0
  49. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/src/quickdbd_cli/project.py +0 -0
  50. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/LICENSE +0 -0
  51. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/README.md +0 -0
  52. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/catalog.json +0 -0
  53. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/manifest.json +0 -0
  54. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_dbt.py +0 -0
  55. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_diff.py +0 -0
  56. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_parser.py +0 -0
  57. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_path.py +0 -0
  58. {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.9}/tests/test_pipeline.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.9
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
@@ -121,7 +121,7 @@ A browser tab opens with the diagram and an editor pane.
121
121
  qdbd build schema.txt -o schema.html
122
122
  ```
123
123
 
124
- `schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
124
+ `schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
125
125
 
126
126
  ### 2.5 Export an image
127
127
 
@@ -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
@@ -264,6 +290,25 @@ qdbd path schema/ --from Invoice --to Campaign
264
290
 
265
291
  ![Path from Invoice to Campaign](https://raw.githubusercontent.com/MarkPhamm/quickdbd-cli/main/docs/assets/img/path.png)
266
292
 
293
+ ### 4.4 Share a link
294
+
295
+ `qdbd serve`'s address only works on your machine. To send someone a **link**:
296
+
297
+ ```bash
298
+ qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
299
+ ```
300
+
301
+ ```text
302
+ Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
303
+ Link: https://gistpreview.github.io/?1a2b3c…
304
+ ```
305
+
306
+ - The link opens the full interactive page. Running `qdbd share` again updates the same link.
307
+ - Gists are secret (link-only) by default; `--public` lists it.
308
+ - For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
309
+
310
+ More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
311
+
267
312
  ## 5. dbt projects
268
313
 
269
314
  ### 5.1 Generate the schema from dbt
@@ -288,6 +333,7 @@ See it on a real project: the [jaffle_shop demo](https://markphamm.github.io/qui
288
333
  | Relationships | `relationships` tests (incl. dbt_utils `relationships_where`) and `foreign_key` constraints |
289
334
  | Column types | `catalog.json` |
290
335
  | Tooltips | model and column descriptions |
336
+ | Header colours | dbt `tags` (unless the name or an explicit `# @tag` says otherwise) |
291
337
 
292
338
  Useful options: `--select 'marts.*'` (repeatable), `--no-sources`, `--package all`, `-o schema.txt` for a single file.
293
339
 
@@ -341,7 +387,7 @@ jobs:
341
387
  permissions: { contents: read, pull-requests: write }
342
388
  steps:
343
389
  - uses: actions/checkout@v4
344
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
390
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
345
391
  with:
346
392
  path: erd/ # schema file(s) or folder(s)
347
393
  output: site/index.html
@@ -364,7 +410,7 @@ Run your dbt setup first, then let the action generate the schema:
364
410
 
365
411
  ```yaml
366
412
  # … dbt deps && dbt build && dbt docs generate
367
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
413
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
368
414
  with:
369
415
  dbt-manifest: target/
370
416
  dbt-args: --infer-fks
@@ -379,7 +425,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
379
425
  ```yaml
380
426
  repos:
381
427
  - repo: https://github.com/MarkPhamm/quickdbd-cli
382
- rev: v0.1.7
428
+ rev: v0.1.9
383
429
  hooks:
384
430
  - id: qdbd-check
385
431
  files: ^erd/.*\.txt$
@@ -404,8 +450,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
404
450
 
405
451
  ```bash
406
452
  qdbd check PATH... [--strict] # validate
407
- qdbd serve PATH... [--port 8765] # live editor + drag to arrange
453
+ qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
408
454
  qdbd build PATH... -o diagram.html # interactive HTML
455
+ qdbd share PATH... [--public] # publish as a gist, print a link
409
456
  qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
410
457
  qdbd layout PATH... [--reset] # (re)write the layout file
411
458
  qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
@@ -93,7 +93,7 @@ A browser tab opens with the diagram and an editor pane.
93
93
  qdbd build schema.txt -o schema.html
94
94
  ```
95
95
 
96
- `schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
96
+ `schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
97
97
 
98
98
  ### 2.5 Export an image
99
99
 
@@ -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
@@ -236,6 +262,25 @@ qdbd path schema/ --from Invoice --to Campaign
236
262
 
237
263
  ![Path from Invoice to Campaign](https://raw.githubusercontent.com/MarkPhamm/quickdbd-cli/main/docs/assets/img/path.png)
238
264
 
265
+ ### 4.4 Share a link
266
+
267
+ `qdbd serve`'s address only works on your machine. To send someone a **link**:
268
+
269
+ ```bash
270
+ qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
271
+ ```
272
+
273
+ ```text
274
+ Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
275
+ Link: https://gistpreview.github.io/?1a2b3c…
276
+ ```
277
+
278
+ - The link opens the full interactive page. Running `qdbd share` again updates the same link.
279
+ - Gists are secret (link-only) by default; `--public` lists it.
280
+ - For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
281
+
282
+ More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
283
+
239
284
  ## 5. dbt projects
240
285
 
241
286
  ### 5.1 Generate the schema from dbt
@@ -260,6 +305,7 @@ See it on a real project: the [jaffle_shop demo](https://markphamm.github.io/qui
260
305
  | Relationships | `relationships` tests (incl. dbt_utils `relationships_where`) and `foreign_key` constraints |
261
306
  | Column types | `catalog.json` |
262
307
  | Tooltips | model and column descriptions |
308
+ | Header colours | dbt `tags` (unless the name or an explicit `# @tag` says otherwise) |
263
309
 
264
310
  Useful options: `--select 'marts.*'` (repeatable), `--no-sources`, `--package all`, `-o schema.txt` for a single file.
265
311
 
@@ -313,7 +359,7 @@ jobs:
313
359
  permissions: { contents: read, pull-requests: write }
314
360
  steps:
315
361
  - uses: actions/checkout@v4
316
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
362
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
317
363
  with:
318
364
  path: erd/ # schema file(s) or folder(s)
319
365
  output: site/index.html
@@ -336,7 +382,7 @@ Run your dbt setup first, then let the action generate the schema:
336
382
 
337
383
  ```yaml
338
384
  # … dbt deps && dbt build && dbt docs generate
339
- - uses: MarkPhamm/quickdbd-cli@v0.1.7
385
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
340
386
  with:
341
387
  dbt-manifest: target/
342
388
  dbt-args: --infer-fks
@@ -351,7 +397,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
351
397
  ```yaml
352
398
  repos:
353
399
  - repo: https://github.com/MarkPhamm/quickdbd-cli
354
- rev: v0.1.7
400
+ rev: v0.1.9
355
401
  hooks:
356
402
  - id: qdbd-check
357
403
  files: ^erd/.*\.txt$
@@ -376,8 +422,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
376
422
 
377
423
  ```bash
378
424
  qdbd check PATH... [--strict] # validate
379
- qdbd serve PATH... [--port 8765] # live editor + drag to arrange
425
+ qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
380
426
  qdbd build PATH... -o diagram.html # interactive HTML
427
+ qdbd share PATH... [--public] # publish as a gist, print a link
381
428
  qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
382
429
  qdbd layout PATH... [--reset] # (re)write the layout file
383
430
  qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
@@ -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.9"
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.9"
@@ -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("");
@@ -47,7 +47,8 @@ def cmd_check(args) -> int:
47
47
  return 0 if ok else 1
48
48
 
49
49
 
50
- def cmd_build(args) -> int:
50
+ def _built_page(args) -> tuple[str, dict] | int:
51
+ """Compile, select and lay out like `build`; returns (html, model) or an exit code."""
51
52
  from .html import render_page
52
53
 
53
54
  project = _open(args)
@@ -80,13 +81,41 @@ def cmd_build(args) -> int:
80
81
  print(f"error: {e}", file=sys.stderr)
81
82
  return 2
82
83
  raise
84
+ return render_page(model, pos, _title(project)), model
85
+
86
+
87
+ def cmd_build(args) -> int:
88
+ res = _built_page(args)
89
+ if isinstance(res, int):
90
+ return res
91
+ page, model = res
83
92
  out = Path(args.output)
84
93
  out.parent.mkdir(parents=True, exist_ok=True)
85
- out.write_text(render_page(model, pos, _title(project)), encoding="utf-8")
94
+ out.write_text(page, encoding="utf-8")
86
95
  print(f"Wrote {out} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
87
96
  return 0
88
97
 
89
98
 
99
+ def cmd_share(args) -> int:
100
+ from . import share
101
+
102
+ res = _built_page(args)
103
+ if isinstance(res, int):
104
+ return res
105
+ page, model = res
106
+ key = share.share_key(args.paths, selector_expr(args.select, args.exclude))
107
+ try:
108
+ r = share.publish(page, _title(_open(args)), key, public=args.public, new=args.new)
109
+ except share.ShareError as e:
110
+ print(f"error: {e}", file=sys.stderr)
111
+ return 2
112
+ verb = "Updated" if r.updated else "Shared"
113
+ print(f"{verb} {len(model['tables'])} tables ({'public' if r.public else 'secret'} gist {r.gist_url})")
114
+ print(f"Link: {r.link}")
115
+ print(f" (if that doesn't load: {r.alt_link})")
116
+ return 0
117
+
118
+
90
119
  def cmd_export(args) -> int:
91
120
  from . import export
92
121
 
@@ -289,7 +318,7 @@ def cmd_serve(args) -> int:
289
318
  from .serve import serve
290
319
 
291
320
  project = _open(args)
292
- return serve(project, port=args.port, open_browser=not args.no_open)
321
+ return serve(project, port=args.port, open_browser=not args.no_open, read_only=args.read_only)
293
322
 
294
323
 
295
324
  def build_parser() -> argparse.ArgumentParser:
@@ -328,6 +357,19 @@ def build_parser() -> argparse.ArgumentParser:
328
357
  sp.add_argument("--strict", action="store_true")
329
358
  sp.set_defaults(func=cmd_build)
330
359
 
360
+ sp = sub.add_parser("share", help="publish the diagram as a GitHub gist and print a link (needs the gh CLI)")
361
+ paths(sp)
362
+ selectors(sp)
363
+ sp.add_argument(
364
+ "--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
365
+ )
366
+ sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
367
+ sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
368
+ sp.add_argument("--strict", action="store_true")
369
+ sp.add_argument("--public", action="store_true", help="list the gist publicly (default: secret, link-only)")
370
+ sp.add_argument("--new", action="store_true", help="make a new gist instead of updating the last one")
371
+ sp.set_defaults(func=cmd_share)
372
+
331
373
  sp = sub.add_parser("export", help="write a PNG, SVG or draw.io file")
332
374
  paths(sp)
333
375
  sp.add_argument("-o", "--output", required=True, help="out.png, out.svg or out.drawio")
@@ -398,6 +440,11 @@ def build_parser() -> argparse.ArgumentParser:
398
440
  sp.add_argument("--port", type=int, default=8765)
399
441
  sp.add_argument("--layout")
400
442
  sp.add_argument("--no-open", action="store_true", help="don't open a browser")
443
+ sp.add_argument(
444
+ "--read-only",
445
+ action="store_true",
446
+ help="live view only: no editor, nothing saved; safe to share through a tunnel",
447
+ )
401
448
  sp.set_defaults(func=cmd_serve)
402
449
  return p
403
450
 
@@ -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,17 +88,31 @@ 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
 
94
- def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
95
+ # Read-only live view: refetch the model whenever the server's version changes. Relative URLs, so it
96
+ # also works behind a tunnel or a path prefix.
97
+ LIVE_JS = (
98
+ "(function(){var v=null;new EventSource('events').onmessage=function(e){"
99
+ "if(v!==null&&e.data!==v)fetch('state').then(function(r){return r.json();})"
100
+ ".then(function(s){window.qdbdViewer.setData(s.model,s.positions);});v=e.data;};})();"
101
+ )
102
+
103
+
104
+ def render_page(model: dict, positions: dict, title: str, serve: bool = False, live: bool = False) -> str:
105
+ """The self-contained page. ``serve``: editor pane and saved dragging. ``live``: read-only, but reloads
106
+ when the server's schema changes (``qdbd serve --read-only``)."""
95
107
  data = {"model": model, "positions": positions, "serve": serve, "title": title}
96
- hint = (
97
- "Drag tables to arrange them (saved). Click a table for details. Esc clears."
98
- if serve
99
- else "Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
100
- )
108
+ if serve:
109
+ hint = "Drag tables to arrange them (saved). Click a table for details. Esc clears."
110
+ elif live:
111
+ hint = "Live, read-only view: updates when the schema changes. Drag tables to rearrange (not saved)."
112
+ else:
113
+ hint = (
114
+ "Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
115
+ )
101
116
  body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint, filter_help=FILTER_HELP)
102
117
  head_extra = ""
103
118
  scripts = ""
@@ -108,6 +123,8 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
108
123
  f'<script src="{CODEMIRROR}/addon/mode/simple.min.js"></script>'
109
124
  f"<script>{_asset('editor.js')}</script>"
110
125
  )
126
+ elif live:
127
+ scripts = f"<script>{LIVE_JS}</script>"
111
128
  return (
112
129
  '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
113
130
  '<meta name="viewport" content="width=device-width, initial-scale=1">'
@@ -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]: