quickdbd-cli 0.1.4__tar.gz → 0.1.6__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.6}/.gitignore +2 -0
  2. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/PKG-INFO +13 -5
  3. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/README.md +9 -4
  4. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/pyproject.toml +2 -1
  5. quickdbd_cli-0.1.6/src/quickdbd_cli/__init__.py +1 -0
  6. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/render.js +116 -2
  7. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/style.css +27 -2
  8. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/viewer.js +108 -10
  9. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/cli.py +93 -11
  10. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/dbt.py +133 -3
  11. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/export.py +16 -4
  12. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/html.py +3 -2
  13. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/layout.py +54 -0
  14. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/project.py +7 -1
  15. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_dbt.py +93 -0
  16. quickdbd_cli-0.1.6/tests/test_path.py +67 -0
  17. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_pipeline.py +58 -0
  18. quickdbd_cli-0.1.4/src/quickdbd_cli/__init__.py +0 -1
  19. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/LICENSE +0 -0
  20. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/NOTICE +0 -0
  21. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/quickdbd-sample.txt +0 -0
  22. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/finance.txt +0 -0
  23. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/hr.txt +0 -0
  24. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/inventory.txt +0 -0
  25. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/marketing.txt +0 -0
  26. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/qdbd.layout.json +0 -0
  27. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/examples/synthetic/sales.txt +0 -0
  28. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/scripts/gen_font_metrics.py +0 -0
  29. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/scripts/gen_schema.py +0 -0
  30. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/scripts/release.sh +0 -0
  31. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/__main__.py +0 -0
  32. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  33. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  34. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  35. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  36. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  37. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/editor.js +0 -0
  38. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  39. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  40. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  41. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/diff.py +0 -0
  42. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/jsrt.py +0 -0
  43. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/measure.py +0 -0
  44. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/model.py +0 -0
  45. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/parser.py +0 -0
  46. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/src/quickdbd_cli/serve.py +0 -0
  47. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/LICENSE +0 -0
  48. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/README.md +0 -0
  49. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/catalog.json +0 -0
  50. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/manifest.json +0 -0
  51. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_diff.py +0 -0
  52. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_parser.py +0 -0
  53. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_select.py +0 -0
  54. {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.6}/tests/test_serve.py +0 -0
@@ -220,3 +220,5 @@ __marimo__/
220
220
  .streamlit/secrets.toml
221
221
  .venv/
222
222
  .superpowers/
223
+ site/
224
+ jaffle-erd/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
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.
@@ -78,6 +83,8 @@ qdbd build erd/ -o erd.html
78
83
  - **Relationships:** `relationships` tests (including dbt_utils `relationships_where`) and `foreign_key` constraints.
79
84
  - **Primary keys:** `primary_key` constraints, or else a column with both `unique` and `not_null` tests.
80
85
  - **Docs:** model and column descriptions become doc comments, shown as tooltips.
86
+ - **`--infer-fks`:** also links `customer_id`-style columns to the table they name, when no relationship is declared. Ambiguous matches are skipped, never guessed.
87
+ - **`--check`:** in CI, fails when the committed files are out of date.
81
88
  - **Options:**
82
89
  - `--select 'marts.*'` narrows by name, fqn or path (repeatable)
83
90
  - `--package all` includes installed packages
@@ -129,11 +136,12 @@ The selector `diff:modified` (or `diff:added` / `diff:changed`) works like dbt's
129
136
 
130
137
  ```bash
131
138
  qdbd check PATH... [--strict] # validate; "No errors found. Good job!"
132
- qdbd build PATH... -o diagram.html # shareable interactive HTML (positions locked)
139
+ qdbd build PATH... -o diagram.html # shareable interactive HTML
133
140
  qdbd serve PATH... [--port 8765] # live editor + drag-to-arrange
134
141
  qdbd export PATH... -o out.png [--focus Customer --depth 1] # PNG, SVG or .drawio
135
142
  qdbd layout PATH... [--reset] # (re)write the layout file
136
143
  qdbd import dbt target/manifest.json -o erd/ # generate schema files from a dbt project
144
+ qdbd path PATH... --from A --to B # shortest join path + SQL JOIN snippet
137
145
  ```
138
146
 
139
147
  `PATH` is a `.txt` file or a folder. Every `*.txt` under a folder is read recursively.
@@ -177,7 +185,7 @@ jobs:
177
185
  permissions: { contents: read, pull-requests: write }
178
186
  steps:
179
187
  - uses: actions/checkout@v4
180
- - uses: MarkPhamm/quickdbd-cli@v0.1.4
188
+ - uses: MarkPhamm/quickdbd-cli@v0.1.6
181
189
  with:
182
190
  path: erd/ # schema file(s) or folder(s)
183
191
  output: site/index.html
@@ -201,7 +209,7 @@ Outputs are `html`, `changed` and `diff-file`.
201
209
  ```yaml
202
210
  repos:
203
211
  - repo: https://github.com/MarkPhamm/quickdbd-cli
204
- rev: v0.1.4
212
+ rev: v0.1.6
205
213
  hooks:
206
214
  - id: qdbd-check
207
215
  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.
@@ -53,6 +55,8 @@ qdbd build erd/ -o erd.html
53
55
  - **Relationships:** `relationships` tests (including dbt_utils `relationships_where`) and `foreign_key` constraints.
54
56
  - **Primary keys:** `primary_key` constraints, or else a column with both `unique` and `not_null` tests.
55
57
  - **Docs:** model and column descriptions become doc comments, shown as tooltips.
58
+ - **`--infer-fks`:** also links `customer_id`-style columns to the table they name, when no relationship is declared. Ambiguous matches are skipped, never guessed.
59
+ - **`--check`:** in CI, fails when the committed files are out of date.
56
60
  - **Options:**
57
61
  - `--select 'marts.*'` narrows by name, fqn or path (repeatable)
58
62
  - `--package all` includes installed packages
@@ -104,11 +108,12 @@ The selector `diff:modified` (or `diff:added` / `diff:changed`) works like dbt's
104
108
 
105
109
  ```bash
106
110
  qdbd check PATH... [--strict] # validate; "No errors found. Good job!"
107
- qdbd build PATH... -o diagram.html # shareable interactive HTML (positions locked)
111
+ qdbd build PATH... -o diagram.html # shareable interactive HTML
108
112
  qdbd serve PATH... [--port 8765] # live editor + drag-to-arrange
109
113
  qdbd export PATH... -o out.png [--focus Customer --depth 1] # PNG, SVG or .drawio
110
114
  qdbd layout PATH... [--reset] # (re)write the layout file
111
115
  qdbd import dbt target/manifest.json -o erd/ # generate schema files from a dbt project
116
+ qdbd path PATH... --from A --to B # shortest join path + SQL JOIN snippet
112
117
  ```
113
118
 
114
119
  `PATH` is a `.txt` file or a folder. Every `*.txt` under a folder is read recursively.
@@ -152,7 +157,7 @@ jobs:
152
157
  permissions: { contents: read, pull-requests: write }
153
158
  steps:
154
159
  - uses: actions/checkout@v4
155
- - uses: MarkPhamm/quickdbd-cli@v0.1.4
160
+ - uses: MarkPhamm/quickdbd-cli@v0.1.6
156
161
  with:
157
162
  path: erd/ # schema file(s) or folder(s)
158
163
  output: site/index.html
@@ -176,7 +181,7 @@ Outputs are `html`, `changed` and `diff-file`.
176
181
  ```yaml
177
182
  repos:
178
183
  - repo: https://github.com/MarkPhamm/quickdbd-cli
179
- rev: v0.1.4
184
+ rev: v0.1.6
180
185
  hooks:
181
186
  - id: qdbd-check
182
187
  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.6"
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.6"
@@ -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();