quickdbd-cli 0.1.4__tar.gz → 0.1.5__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 (54) hide show
  1. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/.gitignore +1 -0
  2. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/PKG-INFO +11 -5
  3. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/README.md +7 -4
  4. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/pyproject.toml +2 -1
  5. quickdbd_cli-0.1.5/src/quickdbd_cli/__init__.py +1 -0
  6. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/render.js +116 -2
  7. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/style.css +27 -2
  8. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/viewer.js +108 -10
  9. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/cli.py +62 -7
  10. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/export.py +16 -4
  11. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/html.py +3 -2
  12. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/layout.py +54 -0
  13. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/project.py +7 -1
  14. quickdbd_cli-0.1.5/tests/test_path.py +67 -0
  15. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_pipeline.py +58 -0
  16. quickdbd_cli-0.1.4/src/quickdbd_cli/__init__.py +0 -1
  17. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/LICENSE +0 -0
  18. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/NOTICE +0 -0
  19. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/quickdbd-sample.txt +0 -0
  20. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/finance.txt +0 -0
  21. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/hr.txt +0 -0
  22. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/inventory.txt +0 -0
  23. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/marketing.txt +0 -0
  24. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/qdbd.layout.json +0 -0
  25. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/sales.txt +0 -0
  26. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/gen_font_metrics.py +0 -0
  27. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/gen_schema.py +0 -0
  28. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/release.sh +0 -0
  29. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/__main__.py +0 -0
  30. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  31. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  32. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  33. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  34. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  35. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/editor.js +0 -0
  36. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  37. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  38. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  39. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/dbt.py +0 -0
  40. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/diff.py +0 -0
  41. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/jsrt.py +0 -0
  42. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/measure.py +0 -0
  43. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/model.py +0 -0
  44. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/parser.py +0 -0
  45. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/serve.py +0 -0
  46. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/LICENSE +0 -0
  47. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/README.md +0 -0
  48. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/catalog.json +0 -0
  49. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/manifest.json +0 -0
  50. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_dbt.py +0 -0
  51. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_diff.py +0 -0
  52. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_parser.py +0 -0
  53. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_select.py +0 -0
  54. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_serve.py +0 -0
@@ -220,3 +220,4 @@ __marimo__/
220
220
  .streamlit/secrets.toml
221
221
  .venv/
222
222
  .superpowers/
223
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
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
@@ -21,6 +21,9 @@ Provides-Extra: dev
21
21
  Requires-Dist: fonttools>=4.50; extra == 'dev'
22
22
  Requires-Dist: pytest>=8; extra == 'dev'
23
23
  Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Provides-Extra: docs
25
+ Requires-Dist: mkdocs-material<10,>=9.5; extra == 'docs'
26
+ Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
24
27
  Description-Content-Type: text/markdown
25
28
 
26
29
  # quickdbd-cli (`qdbd`)
@@ -44,7 +47,7 @@ pipx install quickdbd-cli # or: uvx --from quickdbd-cli qdbd ...
44
47
  qdbd build schema.txt -o schema.html
45
48
  ```
46
49
 
47
- **Live demo (200 tables):** https://markphamm.github.io/quickdbd-cli/
50
+ **Docs:** https://markphamm.github.io/quickdbd-cli/ · **Live demo (200 tables):** https://markphamm.github.io/quickdbd-cli/demo/
48
51
 
49
52
  Not affiliated with QuickDBD. It reads the same text syntax, so schemas move freely between the two.
50
53
 
@@ -60,6 +63,8 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
60
63
  - hover a column to highlight its relationship
61
64
  - hover a dotted-underlined table or column to read its `#` doc comments
62
65
  - PNG/SVG export buttons, and **draw.io**: an editable diagrams.net file using draw.io's ER table shapes and crow's-foot arrows
66
+ - **Path finder:** in a table's detail panel, type another table to see the shortest join path, highlighted on the canvas, with a ready-to-paste SQL `JOIN` (also `qdbd path erd/ --from payments --to customers`)
67
+ - **Regions:** a shaded, labelled area per file or dbt folder (toolbar toggle). `--by-group` on `build` / `export` / `layout` arranges tables so each folder sits together
63
68
  - shareable links: the URL keeps the filter, selected table, collapse mode and zoom; **Link** copies it
64
69
  - **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
65
70
  - **Many files, one diagram:** point it at a folder and foreign keys can cross files.
@@ -129,11 +134,12 @@ The selector `diff:modified` (or `diff:added` / `diff:changed`) works like dbt's
129
134
 
130
135
  ```bash
131
136
  qdbd check PATH... [--strict] # validate; "No errors found. Good job!"
132
- qdbd build PATH... -o diagram.html # shareable interactive HTML (positions locked)
137
+ qdbd build PATH... -o diagram.html # shareable interactive HTML
133
138
  qdbd serve PATH... [--port 8765] # live editor + drag-to-arrange
134
139
  qdbd export PATH... -o out.png [--focus Customer --depth 1] # PNG, SVG or .drawio
135
140
  qdbd layout PATH... [--reset] # (re)write the layout file
136
141
  qdbd import dbt target/manifest.json -o erd/ # generate schema files from a dbt project
142
+ qdbd path PATH... --from A --to B # shortest join path + SQL JOIN snippet
137
143
  ```
138
144
 
139
145
  `PATH` is a `.txt` file or a folder. Every `*.txt` under a folder is read recursively.
@@ -177,7 +183,7 @@ jobs:
177
183
  permissions: { contents: read, pull-requests: write }
178
184
  steps:
179
185
  - uses: actions/checkout@v4
180
- - uses: MarkPhamm/quickdbd-cli@v0.1.4
186
+ - uses: MarkPhamm/quickdbd-cli@v0.1.5
181
187
  with:
182
188
  path: erd/ # schema file(s) or folder(s)
183
189
  output: site/index.html
@@ -201,7 +207,7 @@ Outputs are `html`, `changed` and `diff-file`.
201
207
  ```yaml
202
208
  repos:
203
209
  - repo: https://github.com/MarkPhamm/quickdbd-cli
204
- rev: v0.1.4
210
+ rev: v0.1.5
205
211
  hooks:
206
212
  - id: qdbd-check
207
213
  files: ^erd/.*\.txt$ # point at your schema files
@@ -19,7 +19,7 @@ pipx install quickdbd-cli # or: uvx --from quickdbd-cli qdbd ...
19
19
  qdbd build schema.txt -o schema.html
20
20
  ```
21
21
 
22
- **Live demo (200 tables):** https://markphamm.github.io/quickdbd-cli/
22
+ **Docs:** https://markphamm.github.io/quickdbd-cli/ · **Live demo (200 tables):** https://markphamm.github.io/quickdbd-cli/demo/
23
23
 
24
24
  Not affiliated with QuickDBD. It reads the same text syntax, so schemas move freely between the two.
25
25
 
@@ -35,6 +35,8 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
35
35
  - hover a column to highlight its relationship
36
36
  - hover a dotted-underlined table or column to read its `#` doc comments
37
37
  - PNG/SVG export buttons, and **draw.io**: an editable diagrams.net file using draw.io's ER table shapes and crow's-foot arrows
38
+ - **Path finder:** in a table's detail panel, type another table to see the shortest join path, highlighted on the canvas, with a ready-to-paste SQL `JOIN` (also `qdbd path erd/ --from payments --to customers`)
39
+ - **Regions:** a shaded, labelled area per file or dbt folder (toolbar toggle). `--by-group` on `build` / `export` / `layout` arranges tables so each folder sits together
38
40
  - shareable links: the URL keeps the filter, selected table, collapse mode and zoom; **Link** copies it
39
41
  - **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
40
42
  - **Many files, one diagram:** point it at a folder and foreign keys can cross files.
@@ -104,11 +106,12 @@ The selector `diff:modified` (or `diff:added` / `diff:changed`) works like dbt's
104
106
 
105
107
  ```bash
106
108
  qdbd check PATH... [--strict] # validate; "No errors found. Good job!"
107
- qdbd build PATH... -o diagram.html # shareable interactive HTML (positions locked)
109
+ qdbd build PATH... -o diagram.html # shareable interactive HTML
108
110
  qdbd serve PATH... [--port 8765] # live editor + drag-to-arrange
109
111
  qdbd export PATH... -o out.png [--focus Customer --depth 1] # PNG, SVG or .drawio
110
112
  qdbd layout PATH... [--reset] # (re)write the layout file
111
113
  qdbd import dbt target/manifest.json -o erd/ # generate schema files from a dbt project
114
+ qdbd path PATH... --from A --to B # shortest join path + SQL JOIN snippet
112
115
  ```
113
116
 
114
117
  `PATH` is a `.txt` file or a folder. Every `*.txt` under a folder is read recursively.
@@ -152,7 +155,7 @@ jobs:
152
155
  permissions: { contents: read, pull-requests: write }
153
156
  steps:
154
157
  - uses: actions/checkout@v4
155
- - uses: MarkPhamm/quickdbd-cli@v0.1.4
158
+ - uses: MarkPhamm/quickdbd-cli@v0.1.5
156
159
  with:
157
160
  path: erd/ # schema file(s) or folder(s)
158
161
  output: site/index.html
@@ -176,7 +179,7 @@ Outputs are `html`, `changed` and `diff-file`.
176
179
  ```yaml
177
180
  repos:
178
181
  - repo: https://github.com/MarkPhamm/quickdbd-cli
179
- rev: v0.1.4
182
+ rev: v0.1.5
180
183
  hooks:
181
184
  - id: qdbd-check
182
185
  files: ^erd/.*\.txt$ # point at your schema files
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quickdbd-cli"
7
- version = "0.1.4"
7
+ version = "0.1.5"
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"
@@ -25,6 +25,7 @@ dependencies = [
25
25
 
26
26
  [project.optional-dependencies]
27
27
  dev = ["pytest>=8", "ruff>=0.6", "fonttools>=4.50"]
28
+ docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10"]
28
29
 
29
30
  [project.scripts]
30
31
  qdbd = "quickdbd_cli.cli:main"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.5"
@@ -178,6 +178,41 @@
178
178
  };
179
179
  }
180
180
 
181
+ /* ------------------------------------------------------------------ regions: one shaded area per group */
182
+ var REGION_COLORS = ["#4c78a8", "#59a14f", "#e15759", "#b07aa1", "#f28e2b", "#76b7b2", "#9c755f", "#edc948"];
183
+ var REGION_PAD = 28, REGION_LABEL = 30;
184
+ function regions(model, pos, mode) {
185
+ var geo = geometry(model, mode), groups = {}, names = [];
186
+ model.tables.forEach(function (t) {
187
+ var g = t.group || "", p = pos[t.id];
188
+ if (!g || !p) return;
189
+ if (!groups[g]) { groups[g] = [Infinity, Infinity, -Infinity, -Infinity]; names.push(g); }
190
+ var b = groups[g], tg = geo[t.id];
191
+ b[0] = Math.min(b[0], p[0]); b[1] = Math.min(b[1], p[1]);
192
+ b[2] = Math.max(b[2], p[0] + tg.w); b[3] = Math.max(b[3], p[1] + tg.h);
193
+ });
194
+ var all = {};
195
+ model.tables.forEach(function (t) { if (t.group) all[t.group] = true; });
196
+ var order = Object.keys(all).sort();
197
+ return names.sort().map(function (g) {
198
+ var b = groups[g];
199
+ return {
200
+ name: g,
201
+ color: REGION_COLORS[order.indexOf(g) % REGION_COLORS.length],
202
+ x: b[0] - REGION_PAD, y: b[1] - REGION_PAD - REGION_LABEL,
203
+ w: b[2] - b[0] + 2 * REGION_PAD, h: b[3] - b[1] + 2 * REGION_PAD + REGION_LABEL,
204
+ };
205
+ });
206
+ }
207
+ function renderRegions(model, pos, mode) {
208
+ return regions(model, pos, mode).map(function (r) {
209
+ return '<g class="rg" data-g="' + esc(r.name) + '"><rect x="' + n(r.x) + '" y="' + n(r.y) + '" width="' + n(r.w) + '" height="' + n(r.h) +
210
+ '" rx="14" fill="' + r.color + '" fill-opacity="0.07" stroke="' + r.color + '" stroke-opacity="0.45" stroke-width="1.5"/>' +
211
+ '<text x="' + n(r.x + 16) + '" y="' + n(r.y + 22) + '" fill="' + r.color + '" font-family="' + FONT +
212
+ '" font-size="16" font-weight="bold">' + esc(r.name) + "</text></g>";
213
+ }).join("");
214
+ }
215
+
181
216
  function renderEdges(model, pos, mode, geo) {
182
217
  geo = geo || geometry(model, mode);
183
218
  return model.rels.map(function (r) { return renderEdge(model, geo, pos, r); }).join("");
@@ -186,8 +221,9 @@
186
221
  /* opts.docMarks: underline tables/columns that have doc comments (viewer only, not exports). */
187
222
  function renderInner(model, pos, mode, opts) {
188
223
  var geo = geometry(model, mode), marks = !!(opts && opts.docMarks);
224
+ var rg = opts && opts.regions ? '<g class="regions">' + renderRegions(model, pos, mode) + "</g>" : "";
189
225
  var tables = model.tables.map(function (t) { return pos[t.id] ? renderTable(model, t, geo[t.id], pos[t.id], marks) : ""; }).join("");
190
- return '<g class="edges">' + renderEdges(model, pos, mode, geo) + '</g><g class="tables">' + tables + "</g>";
226
+ return rg + '<g class="edges">' + renderEdges(model, pos, mode, geo) + '</g><g class="tables">' + tables + "</g>";
191
227
  }
192
228
 
193
229
  function bounds(model, pos, mode) {
@@ -208,11 +244,16 @@
208
244
  var mode = opts.mode || "full", m = subset(model, opts.only), pad = opts.pad == null ? 40 : opts.pad;
209
245
  var edgePad = 60;
210
246
  var b = bounds(m, pos, mode);
247
+ if (opts.regions) {
248
+ regions(m, pos, mode).forEach(function (r) {
249
+ b = [Math.min(b[0], r.x), Math.min(b[1], r.y), Math.max(b[2], r.x + r.w), Math.max(b[3], r.y + r.h)];
250
+ });
251
+ }
211
252
  var x0 = b[0] - pad - edgePad, y0 = b[1] - pad, w = b[2] - b[0] + 2 * (pad + edgePad), h = b[3] - b[1] + 2 * pad;
212
253
  var style = opts.fontCss ? "<style>" + opts.fontCss + "</style>" : "";
213
254
  return '<svg xmlns="http://www.w3.org/2000/svg" width="' + n(w) + '" height="' + n(h) + '" viewBox="' + n(x0) + " " + n(y0) + " " + n(w) + " " + n(h) + '">' +
214
255
  style + '<rect x="' + n(x0) + '" y="' + n(y0) + '" width="' + n(w) + '" height="' + n(h) + '" fill="' + C.bg + '"/>' +
215
- renderInner(m, pos, mode) + "</svg>";
256
+ renderInner(m, pos, mode, { regions: opts.regions }) + "</svg>";
216
257
  }
217
258
 
218
259
  /* Tables within `depth` relationship hops of `focus`. */
@@ -252,6 +293,15 @@
252
293
  }
253
294
  function n2(v) { return Math.round(v); }
254
295
  var b = bounds(m, pos, mode);
296
+ if (opts.regions) {
297
+ regions(m, pos, mode).forEach(function (r) {
298
+ cells.push(cell({
299
+ id: id(), value: r.name, vertex: "1", parent: "1",
300
+ style: "rounded=1;arcSize=4;whiteSpace=wrap;html=0;fillColor=" + r.color + ";opacity=12;strokeColor=" + r.color +
301
+ ";verticalAlign=top;align=left;spacingLeft=12;spacingTop=4;fontStyle=1;fontSize=16;fontColor=" + r.color + ";",
302
+ }, g(r.x - b[0] + 40, r.y - b[1] + 40, r.w + KEYW, r.h)));
303
+ });
304
+ }
255
305
  m.tables.forEach(function (t) {
256
306
  var p = pos[t.id];
257
307
  if (!p) return;
@@ -377,8 +427,70 @@
377
427
  return model.tables.filter(function (t) { return (inc === null || inc[t.id]) && !exc[t.id]; }).map(function (t) { return t.id; });
378
428
  }
379
429
 
430
+ /* ------------------------------------------------------------------ path finder
431
+ * Shortest chain of relationships between two tables (relationships are walked in either
432
+ * direction). Each step says which columns join. */
433
+ function findPath(model, from, to) {
434
+ if (from === to) return [];
435
+ var adj = {};
436
+ model.rels.forEach(function (r, i) {
437
+ (adj[r.a.t] = adj[r.a.t] || []).push({ to: r.b.t, i: i, fwd: true });
438
+ (adj[r.b.t] = adj[r.b.t] || []).push({ to: r.a.t, i: i, fwd: false });
439
+ });
440
+ var prev = {}, queue = [from], seen = {};
441
+ seen[from] = true;
442
+ while (queue.length) {
443
+ var cur = queue.shift();
444
+ if (cur === to) break;
445
+ (adj[cur] || []).forEach(function (e) {
446
+ if (seen[e.to]) return;
447
+ seen[e.to] = true;
448
+ prev[e.to] = { from: cur, e: e };
449
+ queue.push(e.to);
450
+ });
451
+ }
452
+ if (!seen[to]) return null;
453
+ var steps = [], at = to;
454
+ while (at !== from) {
455
+ var pv = prev[at], r = model.rels[pv.e.i];
456
+ var here = pv.e.fwd ? r.a : r.b, there = pv.e.fwd ? r.b : r.a; // here = side of pv.from
457
+ steps.unshift({ rel: pv.e.i, from: { t: pv.from, c: here.c, card: here.card }, to: { t: at, c: there.c, card: there.card } });
458
+ at = pv.from;
459
+ }
460
+ return steps;
461
+ }
462
+
463
+ var SQL_RESERVED = ("all and any as asc between by case check column constraint create cross default delete desc distinct " +
464
+ "drop else end exists foreign from full grant group having in index inner insert intersect into is join key left like " +
465
+ "limit not null offset on or order outer primary references right select set table then to union unique update user " +
466
+ "using values view when where with").split(" ");
467
+ function sqlIdent(name) {
468
+ var plain = /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && SQL_RESERVED.indexOf(name.toLowerCase()) < 0;
469
+ return plain ? name : '"' + name.replace(/"/g, '""') + '"';
470
+ }
471
+
472
+ /* SQL FROM/JOIN clause following a path. */
473
+ function pathSql(model, from, steps) {
474
+ var byId = {};
475
+ model.tables.forEach(function (t) { byId[t.id] = t; });
476
+ function col(t, c) {
477
+ var x = byId[t].cols.filter(function (k) { return k.id === c; })[0];
478
+ return x ? sqlIdent(x.name) : null;
479
+ }
480
+ var lines = ["FROM " + sqlIdent(byId[from].name)];
481
+ steps.forEach(function (s) {
482
+ var a = byId[s.from.t].name, b = byId[s.to.t].name, ca = col(s.from.t, s.from.c), cb = col(s.to.t, s.to.c);
483
+ if (ca && cb) lines.push("JOIN " + sqlIdent(b) + " ON " + sqlIdent(b) + "." + cb + " = " + sqlIdent(a) + "." + ca);
484
+ else lines.push("JOIN " + sqlIdent(b) + " ON ... -- table-level relationship: join columns not specified");
485
+ });
486
+ return lines.join("\n");
487
+ }
488
+
380
489
  root.qdbd = {
490
+ findPath: findPath,
491
+ pathSql: pathSql,
381
492
  renderDrawio: renderDrawio,
493
+ regions: regions,
382
494
  select: select,
383
495
  subset: subset,
384
496
  colors: C,
@@ -394,5 +506,7 @@
394
506
  root.qdbdRenderSvg = renderSvg;
395
507
  root.qdbdSelect = select;
396
508
  root.qdbdRenderDrawio = renderDrawio;
509
+ root.qdbdFindPath = findPath;
510
+ root.qdbdPathSql = pathSql;
397
511
  root.qdbdNeighbourhood = neighbourhood;
398
512
  })(typeof window !== "undefined" ? window : globalThis);
@@ -22,14 +22,15 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
22
22
  .r.hl .rb { fill: #dbe6f6; fill-opacity: 1; }
23
23
  .r:hover .rb { fill: #eef3fa; fill-opacity: 1; }
24
24
  .t.flash .tb { stroke: #f0a020; stroke-width: 3; }
25
- .t { cursor: pointer; }
26
- .serve .t { cursor: move; }
25
+ .t { cursor: grab; }
26
+ #canvas.panning .t { cursor: grabbing; }
27
27
 
28
28
  /* toolbar (top right, like QuickDBD's zoom controls) */
29
29
  .bar { position: absolute; top: 14px; right: 16px; display: flex; gap: 6px; align-items: center; }
30
30
  .bar button, .seg button { border: 0; background: transparent; color: #555; font: inherit; cursor: pointer;
31
31
  padding: 6px 9px; border-radius: 4px; }
32
32
  .bar button:hover, .seg button:hover { background: #e4e7ea; }
33
+ .bar button.on { background: #fff; color: var(--blue); box-shadow: 0 1px 2px rgba(0,0,0,.12); }
33
34
  .zoomval { min-width: 48px; text-align: center; color: #666; font-variant-numeric: tabular-nums; }
34
35
  .seg { display: flex; background: #e9ecee; border-radius: 6px; padding: 2px; }
35
36
  .seg button.on { background: #fff; color: var(--blue); box-shadow: 0 1px 2px rgba(0,0,0,.12); }
@@ -110,6 +111,20 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
110
111
  padding: 8px 14px; border-radius: 6px; font-size: 12px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 6; }
111
112
  .toast.open { opacity: 1; }
112
113
 
114
+ /* path finder (in the details panel) */
115
+ .details .pathbox { display: flex; gap: 6px; margin: 10px 0 4px; }
116
+ .details .pathbox input { flex: 1; min-width: 0; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; font: inherit; outline: none; }
117
+ .details .pathbox input:focus { border-color: var(--blue); }
118
+ .details .pathres ol { margin: 6px 0 8px 18px; padding: 0; }
119
+ .details .pathres li { border: 0; padding: 2px 0; }
120
+ .details .pathres pre { background: #f6f8fa; border: 1px solid #eceff2; border-radius: 6px; padding: 8px; font-size: 11px;
121
+ overflow-x: auto; margin: 6px 0; }
122
+ .details .pathres .acts a { margin-right: 12px; font-size: 12px; }
123
+ .details .pathres .none { margin-top: 6px; }
124
+ .e.ph .ep { stroke: #f0a020; stroke-width: 3; }
125
+ .e.ph path, .e.ph circle { stroke: #f0a020; }
126
+ .t.ph .tb { stroke: #f0a020; stroke-width: 2.5; }
127
+
113
128
  /* diff banner (build --diff-base) */
114
129
  .diffbar { position: absolute; top: 58px; left: 50%; transform: translateX(-50%); background: #fff; border: 1px solid var(--line);
115
130
  border-radius: 8px; padding: 6px 12px; font-size: 12px; box-shadow: 0 4px 14px rgba(0,0,0,.06); display: none; z-index: 2;
@@ -165,3 +180,13 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
165
180
  .CodeMirror-cursor { border-left-color: #fff !important; }
166
181
  .CodeMirror-selected { background: #4a525a !important; }
167
182
  .lineerr { background: rgba(229,115,115,.18); }
183
+
184
+ /* narrow screens and embedded frames: toolbar to the bottom, smaller minimap */
185
+ @media (max-width: 900px) {
186
+ .bar { top: auto; bottom: 12px; right: auto; left: 12px; flex-wrap: wrap; max-width: calc(100% - 24px);
187
+ background: rgba(255,255,255,.92); border-radius: 8px; padding: 2px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
188
+ .hint { display: none; }
189
+ .minimap { width: 140px; height: 100px; bottom: auto; top: 14px; right: 12px; }
190
+ .explorer { max-width: calc(100% - 180px); }
191
+ .details { left: 12px; right: 12px; width: auto; top: 12px; bottom: 64px; }
192
+ }
@@ -14,6 +14,8 @@
14
14
  filter: "",
15
15
  vm: data.model, // view model: the tables/relationships left after the filter
16
16
  shown: {},
17
+ regions: !!data.model.regions,
18
+ path: null, // {to: id, steps: [...]} from the selected table
17
19
  };
18
20
  var svg = document.getElementById("canvas");
19
21
  var vp = document.getElementById("vp");
@@ -27,7 +29,7 @@
27
29
 
28
30
  /* ------------------------------------------------------------- render */
29
31
  function render() {
30
- vp.innerHTML = Q.renderInner(S.vm, S.pos, S.mode, { docMarks: true });
32
+ vp.innerHTML = Q.renderInner(S.vm, S.pos, S.mode, { docMarks: true, regions: S.regions });
31
33
  applyFocus();
32
34
  drawMinimap();
33
35
  }
@@ -51,11 +53,15 @@
51
53
  return [r.width || 800, r.height || 600];
52
54
  }
53
55
 
54
- function fit() {
55
- var b = Q.bounds(S.vm, S.pos, S.mode), sz = stageSize(), pad = 40;
56
- var k = Math.min((sz[0] - 2 * pad) / (b[2] - b[0]), (sz[1] - 2 * pad) / (b[3] - b[1]), 1.5);
56
+ function fit(only) {
57
+ var b = Q.bounds(only ? Q.subset(S.vm, only) : S.vm, S.pos, S.mode), sz = stageSize(), pad = 40;
58
+ if (S.regions && !only) b = [b[0] - 30, b[1] - 60, b[2] + 30, b[3] + 30]; // room for region borders and labels
59
+ if (only) sz = [sz[0] - (details.classList.contains("open") ? 400 : 0), sz[1]]; // keep clear of the details panel
60
+ // Keep clear of the floating search box / toolbar at the top and, on narrow screens, the toolbar at the bottom.
61
+ var top = 70, bottom = sz[0] < 900 ? 70 : pad, availH = sz[1] - top - bottom;
62
+ var k = Math.min((sz[0] - 2 * pad) / (b[2] - b[0]), availH / (b[3] - b[1]), 1.5);
57
63
  k = Math.max(k, 0.05);
58
- S.view = { k: k, x: (sz[0] - (b[2] - b[0]) * k) / 2 - b[0] * k, y: (sz[1] - (b[3] - b[1]) * k) / 2 - b[1] * k };
64
+ S.view = { k: k, x: (sz[0] - (b[2] - b[0]) * k) / 2 - b[0] * k, y: top + (availH - (b[3] - b[1]) * k) / 2 - b[1] * k };
59
65
  applyView();
60
66
  }
61
67
 
@@ -94,6 +100,28 @@
94
100
  }
95
101
 
96
102
  function applyFocus() {
103
+ vp.querySelectorAll(".ph").forEach(function (el) { el.classList.remove("ph"); });
104
+ if (S.path && S.focus && S.path.steps) {
105
+ var on = {}, edges = [];
106
+ on[S.focus] = true;
107
+ S.path.steps.forEach(function (st) { on[st.to.t] = true; edges.push(st); });
108
+ svg.classList.add("focus");
109
+ vp.querySelectorAll(".t").forEach(function (el) {
110
+ var id = el.getAttribute("data-id");
111
+ el.classList.toggle("dim", !on[id]);
112
+ el.classList.toggle("ph", !!on[id]);
113
+ });
114
+ vp.querySelectorAll(".e").forEach(function (el) {
115
+ var a = el.getAttribute("data-a"), ac = el.getAttribute("data-ac"), b = el.getAttribute("data-b"), bc = el.getAttribute("data-bc");
116
+ var hit = edges.some(function (st) {
117
+ return (st.from.t === a && st.from.c === ac && st.to.t === b && st.to.c === bc) ||
118
+ (st.from.t === b && st.from.c === bc && st.to.t === a && st.to.c === ac);
119
+ });
120
+ el.classList.toggle("dim", !hit);
121
+ el.classList.toggle("ph", hit);
122
+ });
123
+ return;
124
+ }
97
125
  if (!S.focus || !byId[S.focus]) {
98
126
  S.focus = null;
99
127
  svg.classList.remove("focus");
@@ -180,12 +208,13 @@
180
208
  if (ev.button !== 0) return;
181
209
  var tEl = ev.target.closest && ev.target.closest(".t");
182
210
  drag = { sx: ev.clientX, sy: ev.clientY, moved: false, table: tEl ? tEl.getAttribute("data-id") : null, el: tEl };
183
- if (drag.table && S.serve) {
211
+ if (drag.table) { // tables are draggable everywhere; only `qdbd serve` saves the new position
184
212
  drag.start = S.pos[drag.table].slice();
213
+ tEl.parentNode.appendChild(tEl); // bring the grabbed table to the front
185
214
  } else {
186
215
  drag.view = { x: S.view.x, y: S.view.y };
187
- svg.classList.add("panning");
188
216
  }
217
+ svg.classList.add("panning");
189
218
  svg.setPointerCapture(ev.pointerId);
190
219
  });
191
220
  var raf = null;
@@ -215,6 +244,7 @@
215
244
  return;
216
245
  }
217
246
  if (d.start) {
247
+ if (S.regions) render(); // regions are sized from table positions
218
248
  drawMinimap();
219
249
  var name = byId[d.table].name, body = {};
220
250
  body[name] = S.pos[d.table];
@@ -235,13 +265,28 @@
235
265
  function center() { var s = stageSize(); return [s[0] / 2, s[1] / 2]; }
236
266
  document.getElementById("zin").onclick = function () { var c = center(); zoomAt(1.25, c[0], c[1]); };
237
267
  document.getElementById("zout").onclick = function () { var c = center(); zoomAt(0.8, c[0], c[1]); };
238
- document.getElementById("zfit").onclick = fit;
268
+ document.getElementById("zfit").onclick = function () { fit(); };
239
269
  document.querySelectorAll("#modes button").forEach(function (b) {
240
270
  b.onclick = function () {
241
271
  setMode(b.getAttribute("data-mode"));
242
272
  };
243
273
  });
244
274
 
275
+ var regionBtn = document.getElementById("xregions");
276
+ function groupCount() {
277
+ var g = {};
278
+ S.model.tables.forEach(function (t) { if (t.group) g[t.group] = true; });
279
+ return Object.keys(g).length;
280
+ }
281
+ function setRegions(on) {
282
+ S.regions = !!on && groupCount() > 0;
283
+ regionBtn.classList.toggle("on", S.regions);
284
+ regionBtn.style.display = groupCount() > 1 ? "" : "none";
285
+ render();
286
+ scheduleHash();
287
+ }
288
+ regionBtn.onclick = function () { setRegions(!S.regions); };
289
+
245
290
  function setMode(mode) {
246
291
  S.mode = ["full", "keys", "header"].indexOf(mode) >= 0 ? mode : "full";
247
292
  document.querySelectorAll("#modes button").forEach(function (o) { o.classList.toggle("on", o.getAttribute("data-mode") === S.mode); });
@@ -251,6 +296,7 @@
251
296
 
252
297
  /* ------------------------------------------------------------- selection: focus + details */
253
298
  function select(id, opts) {
299
+ if ((id || null) !== S.focus) S.path = null;
254
300
  S.focus = id && byId[id] ? id : null;
255
301
  scheduleHash();
256
302
  applyFocus();
@@ -431,6 +477,10 @@
431
477
  if (t.file) meta.push(Q.esc(t.file) + ":" + t.line);
432
478
  if (meta.length) out.push('<div class="meta">' + meta.join(" &middot; ") + "</div>");
433
479
  if (t.doc) out.push('<div class="doc">' + Q.esc(t.doc) + "</div>");
480
+ out.push('<div class="pathbox"><input id="pathto" list="pathlist" placeholder="Path to another table…" autocomplete="off" value="' +
481
+ (S.path && byId[S.path.to] ? Q.esc(byId[S.path.to].name) : "") + '"></div><datalist id="pathlist">' +
482
+ S.vm.tables.filter(function (x) { return x.id !== id; }).map(function (x) { return '<option value="' + Q.esc(x.name) + '">'; }).join("") +
483
+ '</datalist><div class="pathres" id="pathres">' + pathHtml(id) + "</div>");
434
484
  if (t.diff && t.diff.state === "changed") {
435
485
  var base = (S.model.diff && S.model.diff.base) || "base", ch = [];
436
486
  t.cols.forEach(function (c) {
@@ -460,6 +510,47 @@
460
510
  details.classList.add("open");
461
511
  document.getElementById("stage").classList.add("has-details");
462
512
  }
513
+ /* ------------------------------------------------------------- path finder */
514
+ function pathHtml(id) {
515
+ if (!S.path) return "";
516
+ var to = byId[S.path.to];
517
+ if (!S.path.steps) return '<div class="none">No relationship path from ' + Q.esc(byId[id].name) + " to " + Q.esc(to.name) + " (with the current filter).</div>";
518
+ var items = S.path.steps.map(function (st) {
519
+ return "<li>" + link(st.from.t, st.from.c) + " &rarr; " + link(st.to.t, st.to.c) +
520
+ '<span class="card">' + CARD[st.from.card] + ":" + CARD[st.to.card] + "</span></li>";
521
+ }).join("");
522
+ return "<h3>Path to " + Q.esc(to.name) + " (" + S.path.steps.length + " join" + (S.path.steps.length === 1 ? "" : "s") + ")</h3><ol>" + items +
523
+ "</ol><pre>" + Q.esc(Q.pathSql(S.vm, id, S.path.steps)) + '</pre><div class="acts"><a id="pathsql">Copy SQL</a><a id="pathonly">Show only this path</a><a id="pathclear">Clear</a></div>';
524
+ }
525
+ function findPathTo(name) {
526
+ var target = null;
527
+ S.vm.tables.forEach(function (t) { if (t.name.toLowerCase() === String(name).trim().toLowerCase()) target = t.id; });
528
+ if (!S.focus || !target || target === S.focus) return false;
529
+ S.path = { to: target, steps: Q.findPath(S.vm, S.focus, target) };
530
+ showDetails(S.focus);
531
+ applyFocus();
532
+ if (S.path.steps && !applyingHash) fit([S.focus].concat(S.path.steps.map(function (st) { return st.to.t; })));
533
+ scheduleHash();
534
+ return true;
535
+ }
536
+ dbody.addEventListener("keydown", function (ev) {
537
+ if (ev.target.id === "pathto" && ev.key === "Enter") findPathTo(ev.target.value);
538
+ if (ev.target.id === "pathto" && ev.key === "Escape") ev.stopPropagation();
539
+ });
540
+ dbody.addEventListener("change", function (ev) { if (ev.target.id === "pathto") findPathTo(ev.target.value); });
541
+ dbody.addEventListener("click", function (ev) {
542
+ if (ev.target.id === "pathclear") { S.path = null; showDetails(S.focus); applyFocus(); scheduleHash(); }
543
+ else if (ev.target.id === "pathonly" && S.path && S.path.steps) {
544
+ var names = [byId[S.focus].name].concat(S.path.steps.map(function (st) { return byId[st.to.t].name; }));
545
+ // Selector syntax treats spaces, commas, + and a leading ! specially; match those characters with "?".
546
+ applyFilter(names.map(function (n) { return n.replace(/[\s,+!]/g, "?"); }).join(" "));
547
+ } else if (ev.target.id === "pathsql" && S.path && S.path.steps) {
548
+ var sql = Q.pathSql(S.vm, S.focus, S.path.steps);
549
+ if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(sql).then(function () { showToast("SQL copied"); });
550
+ else window.prompt("Copy SQL:", sql);
551
+ }
552
+ });
553
+
463
554
  function closeDetails() {
464
555
  details.classList.remove("open");
465
556
  document.getElementById("stage").classList.remove("has-details");
@@ -519,14 +610,14 @@
519
610
  }
520
611
  function exportSvg() {
521
612
  var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
522
- return Q.renderSvg(S.vm, S.pos, { mode: S.mode, only: only, fontCss: document.getElementById("qdbd-font").textContent });
613
+ return Q.renderSvg(S.vm, S.pos, { mode: S.mode, only: only, regions: S.regions, fontCss: document.getElementById("qdbd-font").textContent });
523
614
  }
524
615
  document.getElementById("xsvg").onclick = function () {
525
616
  download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
526
617
  };
527
618
  document.getElementById("xdrawio").onclick = function () {
528
619
  var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
529
- var xml = Q.renderDrawio(S.vm, S.pos, { mode: S.mode, only: only, title: data.title || "ERD" });
620
+ var xml = Q.renderDrawio(S.vm, S.pos, { mode: S.mode, only: only, title: data.title || "ERD", regions: S.regions });
530
621
  download((data.title || "diagram") + ".drawio", new Blob([xml], { type: "application/xml" }));
531
622
  };
532
623
  document.getElementById("xpng").onclick = function () {
@@ -564,6 +655,9 @@
564
655
 
565
656
  index();
566
657
  if (S.serve) document.body.classList.add("serve");
658
+ if (stageSize()[0] < 900) explorer.classList.add("collapsed"); // small screens / embedded frames
659
+ regionBtn.classList.toggle("on", S.regions);
660
+ regionBtn.style.display = groupCount() > 1 ? "" : "none";
567
661
  computeView();
568
662
  render();
569
663
  drawTree();
@@ -597,7 +691,9 @@
597
691
  var p = new URLSearchParams(), v = S.view, sz = stageSize();
598
692
  if (S.filter) p.set("f", S.filter);
599
693
  if (S.focus && byId[S.focus]) p.set("t", byId[S.focus].name);
694
+ if (S.path && byId[S.path.to]) p.set("p", byId[S.path.to].name);
600
695
  if (S.mode !== "full") p.set("m", S.mode);
696
+ if (S.regions !== !!S.model.regions) p.set("r", S.regions ? "1" : "0");
601
697
  p.set("z", String(Math.round(v.k * 1000) / 1000));
602
698
  p.set("c", Math.round((sz[0] / 2 - v.x) / v.k) + "," + Math.round((sz[1] / 2 - v.y) / v.k));
603
699
  return "#" + p.toString();
@@ -615,10 +711,12 @@
615
711
  applyingHash = true;
616
712
  try {
617
713
  if (p.has("m")) setMode(p.get("m"));
714
+ if (p.has("r")) setRegions(p.get("r") === "1");
618
715
  applyFilter(p.get("f") || "", true);
619
716
  var name = p.get("t"), id = null;
620
717
  if (name) S.model.tables.forEach(function (t) { if (t.name === name) id = t.id; });
621
718
  select(id && S.shown[id] ? id : null);
719
+ if (p.get("p") && S.focus) findPathTo(p.get("p"));
622
720
  var z = parseFloat(p.get("z")), c = (p.get("c") || "").split(",").map(Number);
623
721
  if (z > 0 && c.length === 2 && !isNaN(c[0]) && !isNaN(c[1])) {
624
722
  var sz = stageSize();
@@ -11,6 +11,7 @@ from .model import Schema
11
11
  from .project import Project, SelectionError, selector_expr
12
12
 
13
13
  OK = "No errors found. Good job!"
14
+ BY_GROUP_HELP = "fresh layout that keeps each file / dbt folder together, with regions (ignores saved positions)"
14
15
 
15
16
 
16
17
  def _print_diagnostics(schema: Schema, strict: bool) -> bool:
@@ -58,7 +59,7 @@ def cmd_build(args) -> int:
58
59
  # Diff first so `diff:` selectors work; then select.
59
60
  from . import diff
60
61
 
61
- model, pos = project.build(schema)
62
+ model, pos = project.build(schema, by_group=args.by_group)
62
63
  old = diff.schema_at_ref(args.diff_base, project.inputs)
63
64
  diff.annotate_model(model, diff.diff_schemas(old, schema), args.diff_base)
64
65
  expr = selector_expr(args.select, args.exclude)
@@ -68,7 +69,7 @@ def cmd_build(args) -> int:
68
69
  model = select_model(model, expr)
69
70
  pos = {k: v for k, v in pos.items() if any(t["id"] == k for t in model["tables"])}
70
71
  else:
71
- model, pos = project.build(schema, select=selector_expr(args.select, args.exclude))
72
+ model, pos = project.build(schema, select=selector_expr(args.select, args.exclude), by_group=args.by_group)
72
73
  except SelectionError as e:
73
74
  print(f"error: {e}", file=sys.stderr)
74
75
  return 1
@@ -94,12 +95,13 @@ def cmd_export(args) -> int:
94
95
  if not _print_diagnostics(schema, args.strict):
95
96
  return 1
96
97
  try:
97
- model, pos = project.build(schema, select=selector_expr(args.select, args.exclude))
98
+ model, pos = project.build(schema, select=selector_expr(args.select, args.exclude), by_group=args.by_group)
98
99
  except SelectionError as e:
99
100
  print(f"error: {e}", file=sys.stderr)
100
101
  return 1
102
+ regions = args.regions or args.by_group
101
103
  only = None
102
- if not args.focus and (args.select or args.exclude):
104
+ if not args.focus and (args.select or args.exclude) and not args.by_group:
103
105
  only = [t["id"] for t in model["tables"]]
104
106
  pos = export.focus_positions(model, pos, only) # compact layout for the selection
105
107
  if args.focus:
@@ -117,11 +119,12 @@ def cmd_export(args) -> int:
117
119
  out.parent.mkdir(parents=True, exist_ok=True)
118
120
  if fmt == "drawio":
119
121
  out.write_text(
120
- export.render_drawio(model, pos, only=only, mode=args.mode, title=_title(project)), encoding="utf-8"
122
+ export.render_drawio(model, pos, only=only, mode=args.mode, title=_title(project), regions=regions),
123
+ encoding="utf-8",
121
124
  )
122
125
  print(f"Wrote {out} ({len(only) if only else len(model['tables'])} tables)")
123
126
  return 0
124
- svg = export.render_svg(model, pos, only=only, mode=args.mode, embed_font=(fmt == "svg"))
127
+ svg = export.render_svg(model, pos, only=only, mode=args.mode, embed_font=(fmt == "svg"), regions=regions)
125
128
  if fmt == "svg":
126
129
  out.write_text(svg, encoding="utf-8")
127
130
  else:
@@ -135,7 +138,7 @@ def cmd_layout(args) -> int:
135
138
  schema = project.compile()
136
139
  if not _print_diagnostics(schema, False):
137
140
  return 1
138
- model, pos = project.build(schema, reset=args.reset)
141
+ model, pos = project.build(schema, reset=args.reset, by_group=args.by_group)
139
142
  project.save_positions(model, pos)
140
143
  print(f"Wrote {project.sidecar} ({len(pos)} tables)")
141
144
  return 0
@@ -178,6 +181,48 @@ def cmd_import_dbt(args) -> int:
178
181
  return 0
179
182
 
180
183
 
184
+ def cmd_path(args) -> int:
185
+ from . import export
186
+ from .jsrt import runtime
187
+
188
+ project = _open(args)
189
+ schema = project.compile()
190
+ if not _print_diagnostics(schema, False):
191
+ return 1
192
+ from .measure import model_json
193
+
194
+ model = model_json(schema)
195
+ ends = []
196
+ for name in (args.source, args.target):
197
+ t = export.find_table(model, name)
198
+ if t is None:
199
+ print(f"error: table '{name}' not found", file=sys.stderr)
200
+ return 1
201
+ ends.append(t)
202
+ rt = runtime()
203
+ rt.load("render.js")
204
+ steps = rt.call("qdbdFindPath", model, ends[0]["id"], ends[1]["id"])
205
+ a, b = ends[0]["name"], ends[1]["name"]
206
+ if steps is None:
207
+ print(f"No relationship path between {a} and {b}.")
208
+ return 1
209
+ by_id = {t["id"]: t for t in model["tables"]}
210
+ card = {"one": "1", "many": "N", "zeroOne": "0..1", "zeroMany": "0..N"}
211
+
212
+ def end(e):
213
+ t = by_id[e["t"]]
214
+ col = next((c["name"] for c in t["cols"] if c["id"] == e["c"]), None)
215
+ return f"{t['name']}.{col}" if col else t["name"]
216
+
217
+ print(f"{a} -> {b}: {len(steps)} join(s)")
218
+ for st in steps:
219
+ print(f" {end(st['from'])} -> {end(st['to'])} ({card[st['from']['card']]}:{card[st['to']['card']]})")
220
+ if steps:
221
+ print()
222
+ print(rt.call("qdbdPathSql", model, ends[0]["id"], steps))
223
+ return 0
224
+
225
+
181
226
  def cmd_diff(args) -> int:
182
227
  from . import diff
183
228
 
@@ -252,6 +297,7 @@ def build_parser() -> argparse.ArgumentParser:
252
297
  sp.add_argument(
253
298
  "--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
254
299
  )
300
+ sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
255
301
  sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
256
302
  sp.add_argument("--strict", action="store_true")
257
303
  sp.set_defaults(func=cmd_build)
@@ -264,6 +310,8 @@ def build_parser() -> argparse.ArgumentParser:
264
310
  sp.add_argument("--depth", type=int, default=1, help="relationship hops around --focus (default 1)")
265
311
  sp.add_argument("--mode", choices=["full", "keys", "header"], default="full", help="collapse mode")
266
312
  sp.add_argument("--scale", type=float, default=2.0, help="PNG pixel scale (default 2)")
313
+ sp.add_argument("--regions", action="store_true", help="draw a shaded area per file / dbt folder")
314
+ sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
267
315
  sp.add_argument("--layout")
268
316
  sp.add_argument("--strict", action="store_true")
269
317
  sp.set_defaults(func=cmd_export)
@@ -271,9 +319,16 @@ def build_parser() -> argparse.ArgumentParser:
271
319
  sp = sub.add_parser("layout", help="write table positions to the layout sidecar")
272
320
  paths(sp)
273
321
  sp.add_argument("--reset", action="store_true", help="discard saved positions and auto-layout everything")
322
+ sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
274
323
  sp.add_argument("--layout")
275
324
  sp.set_defaults(func=cmd_layout)
276
325
 
326
+ sp = sub.add_parser("path", help="shortest join path between two tables, with a SQL JOIN snippet")
327
+ paths(sp)
328
+ sp.add_argument("--from", dest="source", required=True, metavar="TABLE")
329
+ sp.add_argument("--to", dest="target", required=True, metavar="TABLE")
330
+ sp.set_defaults(func=cmd_path)
331
+
277
332
  sp = sub.add_parser("diff", help="compare schema versions (two paths, or --base GIT_REF vs working tree)")
278
333
  sp.add_argument("paths", nargs="+", metavar="PATH", help="OLD NEW, or the schema path(s) with --base")
279
334
  sp.add_argument("--base", metavar="GIT_REF", help="compare PATH(s) at this git ref with the working tree")
@@ -45,23 +45,35 @@ def focus_ids(model: dict, table_id: str, depth: int) -> list[str]:
45
45
 
46
46
 
47
47
  def render_svg(
48
- model: dict, positions: dict, only: list[str] | None = None, mode: str = "full", embed_font: bool = True
48
+ model: dict,
49
+ positions: dict,
50
+ only: list[str] | None = None,
51
+ mode: str = "full",
52
+ embed_font: bool = True,
53
+ regions: bool = False,
49
54
  ) -> str:
50
55
  rt = runtime()
51
56
  rt.load("render.js")
52
- opts = {"mode": mode, "only": only}
57
+ opts = {"mode": mode, "only": only, "regions": regions}
53
58
  if embed_font:
54
59
  opts["fontCss"] = font_css()
55
60
  return rt.call("qdbdRenderSvg", model, positions, opts)
56
61
 
57
62
 
58
63
  def render_drawio(
59
- model: dict, positions: dict, only: list[str] | None = None, mode: str = "full", title: str = "ERD"
64
+ model: dict,
65
+ positions: dict,
66
+ only: list[str] | None = None,
67
+ mode: str = "full",
68
+ title: str = "ERD",
69
+ regions: bool = False,
60
70
  ) -> str:
61
71
  """draw.io / diagrams.net file (mxGraph XML) with editable ER tables."""
62
72
  rt = runtime()
63
73
  rt.load("render.js")
64
- return rt.call("qdbdRenderDrawio", model, positions, {"mode": mode, "only": only, "title": title})
74
+ return rt.call(
75
+ "qdbdRenderDrawio", model, positions, {"mode": mode, "only": only, "title": title, "regions": regions}
76
+ )
65
77
 
66
78
 
67
79
  def focus_positions(model: dict, positions: dict, ids: list[str]) -> dict:
@@ -51,6 +51,7 @@ BODY = """
51
51
  <button data-mode="header">Names</button>
52
52
  </div>
53
53
  <div class="sep"></div>
54
+ <button id="xregions" title="Shade an area per file / dbt folder">Regions</button>
54
55
  <button id="xlink" title="Copy a link to this exact view (filter, selected table, zoom)">Link</button>
55
56
  <div class="sep"></div>
56
57
  <button id="xsvg" title="Export SVG">SVG</button>
@@ -93,9 +94,9 @@ FILTER_HELP = (
93
94
  def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
94
95
  data = {"model": model, "positions": positions, "serve": serve, "title": title}
95
96
  hint = (
96
- "Drag tables to move them (saved). Click a table to focus. Esc clears."
97
+ "Drag tables to arrange them (saved). Click a table for details. Esc clears."
97
98
  if serve
98
- else "Click a table to focus its relationships. Esc clears. Scroll to zoom, drag to pan."
99
+ else "Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
99
100
  )
100
101
  body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint, filter_help=FILTER_HELP)
101
102
  head_extra = ""
@@ -63,6 +63,7 @@ def elk_layout(tables: list[dict], rels: list[dict]) -> Positions:
63
63
  "edges": edges,
64
64
  }
65
65
  first = rt.call_async("qdbdElkLayout", graph)
66
+ first = _landscape(tables, {k: (v[0], v[1]) for k, v in first.items()})
66
67
  graph = {
67
68
  "id": "root",
68
69
  "layoutOptions": OVERLAP_OPTIONS,
@@ -78,6 +79,59 @@ def elk_layout(tables: list[dict], rels: list[dict]) -> Positions:
78
79
  return {k: (int(v[0] - min_x), int(v[1] - min_y)) for k, v in raw.items()}
79
80
 
80
81
 
82
+ def _bbox(tables: list[dict], pos: dict) -> tuple[float, float]:
83
+ xs = [pos[t["id"]][0] for t in tables] + [pos[t["id"]][0] + t["w"] for t in tables]
84
+ ys = [pos[t["id"]][1] for t in tables] + [pos[t["id"]][1] + t["h"] for t in tables]
85
+ return max(xs) - min(xs), max(ys) - min(ys)
86
+
87
+
88
+ def _landscape(tables: list[dict], pos: dict) -> dict:
89
+ """Stress layout picks an arbitrary orientation; turn tall results 90 degrees so they fit screens."""
90
+ w, h = _bbox(tables, pos)
91
+ if h <= w * 1.15:
92
+ return pos
93
+ out = {}
94
+ for t in tables:
95
+ cx, cy = pos[t["id"]][0] + t["w"] / 2, pos[t["id"]][1] + t["h"] / 2
96
+ nx, ny = cy, -cx # rotate centres; table boxes stay upright
97
+ out[t["id"]] = (nx - t["w"] / 2, ny - t["h"] / 2)
98
+ return out
99
+
100
+
101
+ GROUP_GAP = 140 # room for region padding + label between groups
102
+
103
+
104
+ def group_layout(tables: list[dict], rels: list[dict]) -> Positions:
105
+ """Lay out each group (file / dbt folder) on its own, then pack the groups as blocks.
106
+
107
+ Keeps a folder's tables together so regions don't overlap. Cross-group relationships are drawn
108
+ but don't influence placement.
109
+ """
110
+ groups: dict[str, list[dict]] = {}
111
+ for t in tables:
112
+ groups.setdefault(t.get("group") or "", []).append(t)
113
+ if len(groups) <= 1:
114
+ return elk_layout(tables, rels)
115
+ blocks = []
116
+ for name in sorted(groups):
117
+ members = groups[name]
118
+ pos = elk_layout(members, rels)
119
+ w, h = _bbox(members, pos)
120
+ blocks.append((name, pos, w, h))
121
+ total = sum((w + GROUP_GAP) * (h + GROUP_GAP) for _, _, w, h in blocks)
122
+ row_w = max(max(w for _, _, w, _ in blocks), (total * 1.6) ** 0.5)
123
+ out: Positions = {}
124
+ x = y = row_h = 0.0
125
+ for _name, pos, w, h in sorted(blocks, key=lambda b: (-b[3], b[0])):
126
+ if x > 0 and x + w > row_w:
127
+ x, y, row_h = 0.0, y + row_h + GROUP_GAP, 0.0
128
+ for k, (px, py) in pos.items():
129
+ out[k] = (int(px + x), int(py + y))
130
+ x += w + GROUP_GAP
131
+ row_h = max(row_h, h)
132
+ return out
133
+
134
+
81
135
  def _overlaps(x: float, y: float, w: float, h: float, rects: list[tuple[float, float, float, float]]) -> bool:
82
136
  for rx, ry, rw, rh in rects:
83
137
  if x < rx + rw + MARGIN and rx < x + w + MARGIN and y < ry + rh + MARGIN and ry < y + h + MARGIN:
@@ -58,7 +58,9 @@ class Project:
58
58
  def compile(self) -> Schema:
59
59
  return compile_sources(self.sources())
60
60
 
61
- def build(self, schema: Schema, reset: bool = False, select: str | None = None) -> tuple[dict, dict]:
61
+ def build(
62
+ self, schema: Schema, reset: bool = False, select: str | None = None, by_group: bool = False
63
+ ) -> tuple[dict, dict]:
62
64
  """Returns (model, positions) with positions keyed by table id as [x, y].
63
65
 
64
66
  ``select`` is a dbt-style selector expression (see ``select_model``); unselected tables are dropped.
@@ -66,6 +68,10 @@ class Project:
66
68
  model = model_json(schema)
67
69
  if select:
68
70
  model = select_model(model, select)
71
+ if by_group: # fresh grouped layout; saved positions are ignored
72
+ model["regions"] = True
73
+ pos = layout.group_layout(model["tables"], model["rels"])
74
+ return model, {k: [v[0], v[1]] for k, v in pos.items()}
69
75
  side = {} if reset else layout.read_sidecar(self.sidecar)
70
76
  pos = layout.resolve_positions(model, side, schema.layout, reset=reset)
71
77
  return model, {k: [v[0], v[1]] for k, v in pos.items()}
@@ -0,0 +1,67 @@
1
+ """Path finder (render.js findPath / pathSql) and `qdbd path`."""
2
+
3
+ from quickdbd_cli.cli import main
4
+ from quickdbd_cli.jsrt import runtime
5
+ from quickdbd_cli.measure import model_json
6
+ from quickdbd_cli.parser import compile_text
7
+
8
+ SCHEMA = """customers
9
+ -
10
+ customer_id PK int
11
+
12
+ "Order"
13
+ -
14
+ order_id PK int
15
+ customer_id int FK >- customers.customer_id
16
+
17
+ payments
18
+ -
19
+ payment_id PK int
20
+ order_id int FK >- Order.order_id
21
+
22
+ island
23
+ -
24
+ id PK int
25
+ """
26
+
27
+
28
+ def js(fn, *args):
29
+ rt = runtime()
30
+ rt.load("render.js")
31
+ return rt.call(fn, *args)
32
+
33
+
34
+ def test_find_path_both_directions():
35
+ m = model_json(compile_text(SCHEMA))
36
+ steps = js("qdbdFindPath", m, "payments", "customers")
37
+ assert [(s["from"]["t"], s["from"]["c"], s["to"]["t"], s["to"]["c"]) for s in steps] == [
38
+ ("payments", "order_id", "Order", "order_id"),
39
+ ("Order", "customer_id", "customers", "customer_id"),
40
+ ]
41
+ back = js("qdbdFindPath", m, "customers", "payments")
42
+ assert [s["to"]["t"] for s in back] == ["Order", "payments"]
43
+ assert js("qdbdFindPath", m, "payments", "island") is None
44
+ assert js("qdbdFindPath", m, "payments", "payments") == []
45
+
46
+
47
+ def test_path_sql_quotes_reserved_words():
48
+ m = model_json(compile_text(SCHEMA))
49
+ steps = js("qdbdFindPath", m, "payments", "customers")
50
+ assert js("qdbdPathSql", m, "payments", steps) == (
51
+ "FROM payments\n"
52
+ 'JOIN "Order" ON "Order".order_id = payments.order_id\n'
53
+ 'JOIN customers ON customers.customer_id = "Order".customer_id'
54
+ )
55
+
56
+
57
+ def test_cli_path(tmp_path, capsys):
58
+ f = tmp_path / "s.txt"
59
+ f.write_text(SCHEMA)
60
+ assert main(["path", str(f), "--from", "payments", "--to", "customers"]) == 0
61
+ out = capsys.readouterr().out
62
+ assert out.startswith("payments -> customers: 2 join(s)")
63
+ assert "payments.order_id -> Order.order_id (N:1)" in out
64
+ assert 'JOIN "Order" ON' in out
65
+ assert main(["path", str(f), "--from", "payments", "--to", "island"]) == 1
66
+ assert "No relationship path" in capsys.readouterr().out
67
+ assert main(["path", str(f), "--from", "nope", "--to", "island"]) == 1
@@ -268,3 +268,61 @@ def test_drawio_export_with_selection(tmp_path, capsys):
268
268
  assert main(["export", str(SYNTH), "-s", "Customer+1", "-o", str(out)]) == 0
269
269
  assert "Wrote" in capsys.readouterr().out
270
270
  assert out.read_text().count("shape=table;") >= 2
271
+
272
+
273
+ def test_small_schema_layout_is_landscape():
274
+ p = Project.open([SAMPLE])
275
+ m = model_json(p.compile())
276
+ pos = layout.elk_layout(m["tables"], m["rels"])
277
+ w, h = layout._bbox(m["tables"], pos)
278
+ assert w >= h # chain-shaped schemas used to come out tall and narrow
279
+
280
+
281
+ def test_landscape_rotation_keeps_tables_upright():
282
+ tables = [{"id": "a", "w": 100, "h": 50}, {"id": "b", "w": 100, "h": 50}]
283
+ tall = {"a": (0, 0), "b": (0, 400)}
284
+ out = layout._landscape(tables, tall)
285
+ w, h = layout._bbox(tables, out)
286
+ assert w > h
287
+ assert layout._landscape(tables, out) == out # already landscape: unchanged
288
+
289
+
290
+ def test_group_layout_keeps_folders_apart():
291
+ p = Project.open([SYNTH])
292
+ m = model_json(p.compile())
293
+ pos = layout.group_layout(m["tables"], m["rels"])
294
+ assert len(pos) == 200
295
+ boxes = {}
296
+ for t in m["tables"]:
297
+ x, y = pos[t["id"]]
298
+ b = boxes.setdefault(t["group"], [x, y, x + t["w"], y + t["h"]])
299
+ b[:] = [min(b[0], x), min(b[1], y), max(b[2], x + t["w"]), max(b[3], y + t["h"])]
300
+ rects = [(b[0], b[1], b[2] - b[0], b[3] - b[1]) for b in boxes.values()]
301
+ assert len(rects) == 5
302
+ assert not any(_overlap(r1, r2) for i, r1 in enumerate(rects) for r2 in rects[i + 1 :])
303
+
304
+
305
+ def test_by_group_cli(tmp_path, capsys):
306
+ work = tmp_path / "s"
307
+ shutil.copytree(SYNTH, work)
308
+ html = tmp_path / "g.html"
309
+ assert main(["build", str(work), "--by-group", "-o", str(html)]) == 0
310
+ assert '"regions":true' in html.read_text()
311
+ svg = tmp_path / "g.svg"
312
+ assert main(["export", str(work), "--by-group", "-o", str(svg)]) == 0
313
+ assert svg.read_text().count('class="rg"') == 5
314
+ drawio = tmp_path / "g.drawio"
315
+ assert main(["export", str(work), "--regions", "-o", str(drawio)]) == 0
316
+ assert drawio.read_text().count("verticalAlign=top;align=left;spacingLeft=12") == 5
317
+ before = (work / "qdbd.layout.json").read_text()
318
+ assert main(["layout", str(work), "--by-group"]) == 0
319
+ assert (work / "qdbd.layout.json").read_text() != before
320
+
321
+
322
+ def test_regions_skip_ungrouped_tables():
323
+ from quickdbd_cli.jsrt import runtime
324
+
325
+ m = model_json(compile_text(SMALL, "one.txt"))
326
+ rt = runtime()
327
+ rt.load("render.js")
328
+ assert rt.call("qdbd.regions", m, {"Customer": [0, 0], "Order": [300, 0]}, "full") == []
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
File without changes
File without changes