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.
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/.gitignore +1 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/PKG-INFO +11 -5
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/README.md +7 -4
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/pyproject.toml +2 -1
- quickdbd_cli-0.1.5/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/render.js +116 -2
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/style.css +27 -2
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/viewer.js +108 -10
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/cli.py +62 -7
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/export.py +16 -4
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/html.py +3 -2
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/layout.py +54 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/project.py +7 -1
- quickdbd_cli-0.1.5/tests/test_path.py +67 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_pipeline.py +58 -0
- quickdbd_cli-0.1.4/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/LICENSE +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/NOTICE +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/qdbd.layout.json +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/scripts/release.sh +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/dbt.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/diff.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/measure.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/src/quickdbd_cli/serve.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/LICENSE +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/README.md +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/catalog.json +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/fixtures/jaffle/manifest.json +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_dbt.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_diff.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_parser.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_select.py +0 -0
- {quickdbd_cli-0.1.4 → quickdbd_cli-0.1.5}/tests/test_serve.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quickdbd-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
26
|
-
.
|
|
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
|
-
|
|
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: (
|
|
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
|
|
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(" · ") + "</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) + " → " + 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)
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
97
|
+
"Drag tables to arrange them (saved). Click a table for details. Esc clears."
|
|
97
98
|
if serve
|
|
98
|
-
else "
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|