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.
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/PKG-INFO +32 -5
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/README.md +31 -4
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/pyproject.toml +1 -1
- quickdbd_cli-0.1.8/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/render.js +5 -4
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/style.css +11 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/viewer.js +29 -2
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/dbt.py +15 -1
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/html.py +2 -1
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/measure.py +10 -4
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/model.py +3 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/parser.py +48 -1
- quickdbd_cli-0.1.8/src/quickdbd_cli/tags.py +95 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_select.py +3 -3
- quickdbd_cli-0.1.8/tests/test_tags.py +196 -0
- quickdbd_cli-0.1.7/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/.gitignore +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/LICENSE +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/NOTICE +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/qdbd.layout.json +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/scripts/release.sh +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/cli.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/diff.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/src/quickdbd_cli/serve.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/LICENSE +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/README.md +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/catalog.json +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/fixtures/jaffle/manifest.json +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_dbt.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_diff.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_parser.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_path.py +0 -0
- {quickdbd_cli-0.1.7 → quickdbd_cli-0.1.8}/tests/test_pipeline.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
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
|
|
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) + '">' +
|
|
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(" · ") + "</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
|
-
|
|
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
|
-
|
|
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, "
|
|
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", "
|
|
136
|
-
assert "error: unknown selector method '
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|