quickdbd-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- quickdbd_cli/__main__.py +5 -0
- quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- quickdbd_cli/assets/editor.js +244 -0
- quickdbd_cli/assets/elk-LICENSE.md +263 -0
- quickdbd_cli/assets/elk.bundled.js +6605 -0
- quickdbd_cli/assets/font-metrics.json +1 -0
- quickdbd_cli/assets/render.js +230 -0
- quickdbd_cli/assets/style.css +83 -0
- quickdbd_cli/assets/viewer.js +337 -0
- quickdbd_cli/cli.py +169 -0
- quickdbd_cli/export.py +79 -0
- quickdbd_cli/html.py +97 -0
- quickdbd_cli/jsrt.py +77 -0
- quickdbd_cli/layout.py +207 -0
- quickdbd_cli/measure.py +111 -0
- quickdbd_cli/model.py +107 -0
- quickdbd_cli/parser.py +717 -0
- quickdbd_cli/project.py +69 -0
- quickdbd_cli/serve.py +210 -0
- quickdbd_cli-0.1.0.dist-info/METADATA +153 -0
- quickdbd_cli-0.1.0.dist-info/RECORD +32 -0
- quickdbd_cli-0.1.0.dist-info/WHEEL +4 -0
- quickdbd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- quickdbd_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- quickdbd_cli-0.1.0.dist-info/licenses/NOTICE +19 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md +263 -0
quickdbd_cli/jsrt.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Embedded JavaScript runtime (V8 via mini-racer) for ELK layout and render.js.
|
|
2
|
+
|
|
3
|
+
mini-racer's built-in setTimeout does not work reliably, so a synchronous timer queue is
|
|
4
|
+
installed instead and drained from Python; ELK only uses setTimeout(0) to fake a worker.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import json
|
|
11
|
+
from functools import cache
|
|
12
|
+
from importlib import resources
|
|
13
|
+
|
|
14
|
+
from py_mini_racer import MiniRacer
|
|
15
|
+
|
|
16
|
+
_SHIM = """
|
|
17
|
+
globalThis.global = globalThis;
|
|
18
|
+
var __q = [];
|
|
19
|
+
globalThis.setTimeout = function (f) { __q.push(f); return __q.length; };
|
|
20
|
+
globalThis.clearTimeout = function () {};
|
|
21
|
+
function __drain() { var n = 0; while (__q.length) { __q.shift()(); n++; } return n; }
|
|
22
|
+
var __res = null, __err = null;
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _asset(name: str) -> str:
|
|
27
|
+
return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Runtime:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.ctx = MiniRacer()
|
|
33
|
+
self.ctx.eval(_SHIM)
|
|
34
|
+
self.loaded: set[str] = set()
|
|
35
|
+
|
|
36
|
+
def load(self, name: str) -> None:
|
|
37
|
+
if name not in self.loaded:
|
|
38
|
+
self.ctx.eval(_asset(name))
|
|
39
|
+
self.loaded.add(name)
|
|
40
|
+
|
|
41
|
+
def call(self, fn: str, *args) -> object:
|
|
42
|
+
"""Call a global JS function with JSON-serialisable args; returns the JSON-decoded result."""
|
|
43
|
+
argv = ", ".join(f"JSON.parse({json.dumps(json.dumps(a))})" for a in args)
|
|
44
|
+
return json.loads(self.ctx.eval(f"JSON.stringify({fn}({argv}))"))
|
|
45
|
+
|
|
46
|
+
def call_async(self, expr_fn: str, *args) -> object:
|
|
47
|
+
"""Call a JS function returning a Promise, draining the timer queue until it settles."""
|
|
48
|
+
argv = ", ".join(f"JSON.parse({json.dumps(json.dumps(a))})" for a in args)
|
|
49
|
+
self.ctx.eval("__res = null; __err = null;")
|
|
50
|
+
self.ctx.eval(
|
|
51
|
+
f"Promise.resolve({expr_fn}({argv})).then("
|
|
52
|
+
"function (r) { __res = JSON.stringify(r); }, function (e) { __err = String(e && e.stack || e); });"
|
|
53
|
+
)
|
|
54
|
+
for _ in range(10000):
|
|
55
|
+
self.ctx.eval("__drain()")
|
|
56
|
+
if self.ctx.eval("__res !== null || __err !== null"):
|
|
57
|
+
break
|
|
58
|
+
err = self.ctx.eval("__err")
|
|
59
|
+
if err:
|
|
60
|
+
raise RuntimeError(f"JavaScript error: {err}")
|
|
61
|
+
res = self.ctx.eval("__res")
|
|
62
|
+
if res is None:
|
|
63
|
+
raise RuntimeError("JavaScript promise did not settle")
|
|
64
|
+
return json.loads(res)
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
try:
|
|
68
|
+
self.ctx.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cache
|
|
74
|
+
def runtime() -> _Runtime:
|
|
75
|
+
rt = _Runtime()
|
|
76
|
+
atexit.register(rt.close)
|
|
77
|
+
return rt
|
quickdbd_cli/layout.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Table positions: sidecar file, QuickDBD [layout] import, ELK auto-layout, new-table placement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .jsrt import runtime
|
|
11
|
+
|
|
12
|
+
_GLUE = """
|
|
13
|
+
function qdbdElkLayout(graph) {
|
|
14
|
+
return new ELK().layout(graph).then(function (r) {
|
|
15
|
+
var out = {};
|
|
16
|
+
(r.children || []).forEach(function (c) { out[c.id] = [Math.round(c.x), Math.round(c.y)]; });
|
|
17
|
+
return out;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Stress layout keeps related tables close together (short relationship lines), then
|
|
23
|
+
# ELK's overlap removal pushes tables apart. Layered layouts were tried and produced very
|
|
24
|
+
# long lines on 200-table schemas.
|
|
25
|
+
STRESS_OPTIONS = {
|
|
26
|
+
"elk.algorithm": "stress",
|
|
27
|
+
"elk.stress.desiredEdgeLength": "250",
|
|
28
|
+
"elk.randomSeed": "1",
|
|
29
|
+
}
|
|
30
|
+
OVERLAP_OPTIONS = {
|
|
31
|
+
"elk.algorithm": "sporeOverlap",
|
|
32
|
+
"elk.spacing.nodeNode": "40",
|
|
33
|
+
"elk.randomSeed": "1",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
MARGIN = 30
|
|
37
|
+
Positions = dict[str, tuple[int, int]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def elk_layout(tables: list[dict], rels: list[dict]) -> Positions:
|
|
41
|
+
"""Lay out ``tables`` (model_json table dicts) with ELK; only rels between them are used."""
|
|
42
|
+
if not tables:
|
|
43
|
+
return {}
|
|
44
|
+
ids = {t["id"] for t in tables}
|
|
45
|
+
rt = runtime()
|
|
46
|
+
rt.load("elk.bundled.js")
|
|
47
|
+
if "glue" not in rt.loaded:
|
|
48
|
+
rt.ctx.eval(_GLUE)
|
|
49
|
+
rt.loaded.add("glue")
|
|
50
|
+
edges = []
|
|
51
|
+
seen = set()
|
|
52
|
+
for r in rels:
|
|
53
|
+
a, b = r["a"]["t"], r["b"]["t"]
|
|
54
|
+
# Edges point from the referencing (many/FK) side to the referenced side.
|
|
55
|
+
src, dst = (a, b) if r["a"]["card"] in ("many", "zeroMany") or r["b"]["card"] == "one" else (b, a)
|
|
56
|
+
if src in ids and dst in ids and src != dst and (src, dst) not in seen:
|
|
57
|
+
seen.add((src, dst))
|
|
58
|
+
edges.append({"id": f"e{len(edges)}", "sources": [src], "targets": [dst]})
|
|
59
|
+
graph = {
|
|
60
|
+
"id": "root",
|
|
61
|
+
"layoutOptions": STRESS_OPTIONS,
|
|
62
|
+
"children": [{"id": t["id"], "width": t["w"], "height": t["h"]} for t in tables],
|
|
63
|
+
"edges": edges,
|
|
64
|
+
}
|
|
65
|
+
first = rt.call_async("qdbdElkLayout", graph)
|
|
66
|
+
graph = {
|
|
67
|
+
"id": "root",
|
|
68
|
+
"layoutOptions": OVERLAP_OPTIONS,
|
|
69
|
+
"children": [
|
|
70
|
+
{"id": t["id"], "width": t["w"], "height": t["h"], "x": first[t["id"]][0], "y": first[t["id"]][1]}
|
|
71
|
+
for t in tables
|
|
72
|
+
],
|
|
73
|
+
"edges": [],
|
|
74
|
+
}
|
|
75
|
+
raw = rt.call_async("qdbdElkLayout", graph)
|
|
76
|
+
min_x = min(v[0] for v in raw.values())
|
|
77
|
+
min_y = min(v[1] for v in raw.values())
|
|
78
|
+
return {k: (int(v[0] - min_x), int(v[1] - min_y)) for k, v in raw.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _overlaps(x: float, y: float, w: float, h: float, rects: list[tuple[float, float, float, float]]) -> bool:
|
|
82
|
+
for rx, ry, rw, rh in rects:
|
|
83
|
+
if x < rx + rw + MARGIN and rx < x + w + MARGIN and y < ry + rh + MARGIN and ry < y + h + MARGIN:
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _free_spot(cx: float, cy: float, w: float, h: float, rects, step: int = 30) -> tuple[int, int]:
|
|
89
|
+
"""Nearest non-overlapping top-left position to centre (cx, cy), searched in square rings."""
|
|
90
|
+
x0, y0 = cx - w / 2, cy - h / 2
|
|
91
|
+
for ring in range(0, 400):
|
|
92
|
+
cands = []
|
|
93
|
+
for dx in range(-ring, ring + 1):
|
|
94
|
+
for dy in range(-ring, ring + 1):
|
|
95
|
+
if max(abs(dx), abs(dy)) == ring:
|
|
96
|
+
cands.append((dx * dx + dy * dy, dx, dy))
|
|
97
|
+
for _, dx, dy in sorted(cands):
|
|
98
|
+
x, y = x0 + dx * step, y0 + dy * step
|
|
99
|
+
if not _overlaps(x, y, w, h, rects):
|
|
100
|
+
return int(round(x)), int(round(y))
|
|
101
|
+
return int(round(x0)), int(round(y0))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def place_missing(tables: list[dict], rels: list[dict], positions: Positions) -> Positions:
|
|
105
|
+
"""Positions for every table. Existing positions never move.
|
|
106
|
+
|
|
107
|
+
Tables with already-placed neighbours go into the nearest free space around those neighbours;
|
|
108
|
+
the rest are laid out together with ELK and put below the existing diagram.
|
|
109
|
+
"""
|
|
110
|
+
by_id = {t["id"]: t for t in tables}
|
|
111
|
+
pos: Positions = {k: v for k, v in positions.items() if k in by_id}
|
|
112
|
+
missing = [t["id"] for t in tables if t["id"] not in pos]
|
|
113
|
+
if not missing:
|
|
114
|
+
return pos
|
|
115
|
+
if not pos:
|
|
116
|
+
return elk_layout(tables, rels)
|
|
117
|
+
|
|
118
|
+
neighbours: dict[str, list[str]] = {t["id"]: [] for t in tables}
|
|
119
|
+
for r in rels:
|
|
120
|
+
a, b = r["a"]["t"], r["b"]["t"]
|
|
121
|
+
if a != b and a in neighbours and b in neighbours:
|
|
122
|
+
neighbours[a].append(b)
|
|
123
|
+
neighbours[b].append(a)
|
|
124
|
+
order = {tid: i for i, tid in enumerate(t["id"] for t in tables)}
|
|
125
|
+
rects = [(x, y, by_id[k]["w"], by_id[k]["h"]) for k, (x, y) in pos.items()]
|
|
126
|
+
|
|
127
|
+
pending = set(missing)
|
|
128
|
+
while True:
|
|
129
|
+
ready = [(sum(1 for n in neighbours[t] if n in pos), t) for t in pending]
|
|
130
|
+
ready = [(n, t) for n, t in ready if n > 0]
|
|
131
|
+
if not ready:
|
|
132
|
+
break
|
|
133
|
+
ready.sort(key=lambda nt: (-nt[0], order[nt[1]]))
|
|
134
|
+
tid = ready[0][1]
|
|
135
|
+
t = by_id[tid]
|
|
136
|
+
placed = [n for n in neighbours[tid] if n in pos]
|
|
137
|
+
cx = sum(pos[n][0] + by_id[n]["w"] / 2 for n in placed) / len(placed)
|
|
138
|
+
cy = sum(pos[n][1] + by_id[n]["h"] / 2 for n in placed) / len(placed)
|
|
139
|
+
x, y = _free_spot(cx, cy, t["w"], t["h"], rects)
|
|
140
|
+
pos[tid] = (x, y)
|
|
141
|
+
rects.append((x, y, t["w"], t["h"]))
|
|
142
|
+
pending.discard(tid)
|
|
143
|
+
|
|
144
|
+
if pending:
|
|
145
|
+
rest = [by_id[t] for t in missing if t in pending]
|
|
146
|
+
block = elk_layout(rest, rels)
|
|
147
|
+
min_x = min(r[0] for r in rects)
|
|
148
|
+
max_y = max(r[1] + r[3] for r in rects)
|
|
149
|
+
bx = min(v[0] for v in block.values())
|
|
150
|
+
by = min(v[1] for v in block.values())
|
|
151
|
+
for tid, (x, y) in block.items():
|
|
152
|
+
pos[tid] = (x - bx + min_x, y - by + max_y + 120)
|
|
153
|
+
return pos
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- sidecar
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def default_sidecar(files: list[Path], inputs: list[Path]) -> Path:
|
|
160
|
+
if len(inputs) == 1 and inputs[0].is_file():
|
|
161
|
+
f = inputs[0]
|
|
162
|
+
return f.with_name(f.stem + ".layout.json")
|
|
163
|
+
if len(inputs) == 1 and inputs[0].is_dir():
|
|
164
|
+
return inputs[0] / "qdbd.layout.json"
|
|
165
|
+
common = Path(os.path.commonpath([str(p.resolve().parent) for p in files])) if files else Path.cwd()
|
|
166
|
+
return common / "qdbd.layout.json"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_sidecar(path: Path) -> dict[str, tuple[int, int]]:
|
|
170
|
+
"""Returns {table name: (x, y)}."""
|
|
171
|
+
if not path.exists():
|
|
172
|
+
return {}
|
|
173
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
174
|
+
return {name: (int(v["x"]), int(v["y"])) for name, v in data.get("tables", {}).items()}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def write_sidecar(path: Path, by_name: dict[str, tuple[int, int]]) -> None:
|
|
178
|
+
data = {
|
|
179
|
+
"version": 1,
|
|
180
|
+
"tables": {n: {"x": int(x), "y": int(y)} for n, (x, y) in sorted(by_name.items())},
|
|
181
|
+
}
|
|
182
|
+
text = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
183
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".qdbd-", suffix=".tmp")
|
|
185
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
186
|
+
f.write(text)
|
|
187
|
+
os.replace(tmp, path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_positions(
|
|
191
|
+
model: dict, sidecar: dict[str, tuple[int, int]], qdbd_layout: dict, reset: bool = False
|
|
192
|
+
) -> Positions:
|
|
193
|
+
"""Sidecar (by table name) > QuickDBD [layout] section > auto placement."""
|
|
194
|
+
tables = model["tables"]
|
|
195
|
+
pos: Positions = {}
|
|
196
|
+
if not reset:
|
|
197
|
+
for t in tables:
|
|
198
|
+
if t["name"] in sidecar:
|
|
199
|
+
pos[t["id"]] = sidecar[t["name"]]
|
|
200
|
+
elif t["id"] in qdbd_layout:
|
|
201
|
+
x, y = qdbd_layout[t["id"]]
|
|
202
|
+
pos[t["id"]] = (int(x), int(y))
|
|
203
|
+
return place_missing(tables, model["rels"], pos)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def positions_by_name(model: dict, pos: Positions) -> dict[str, tuple[int, int]]:
|
|
207
|
+
return {t["name"]: pos[t["id"]] for t in model["tables"] if t["id"] in pos}
|
quickdbd_cli/measure.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Text measurement and the diagram model handed to render.js.
|
|
2
|
+
|
|
3
|
+
Widths come from a committed per-glyph table for the bundled Inter font
|
|
4
|
+
(see scripts/gen_font_metrics.py), so Python, the browser, and the PNG export agree.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from functools import cache
|
|
11
|
+
from importlib import resources
|
|
12
|
+
|
|
13
|
+
from .model import Column, Schema, Table
|
|
14
|
+
|
|
15
|
+
STYLE = {
|
|
16
|
+
"font": "Inter",
|
|
17
|
+
"headerH": 32,
|
|
18
|
+
"headerSize": 14,
|
|
19
|
+
"rowH": 24,
|
|
20
|
+
"rowSize": 12,
|
|
21
|
+
"padX": 12,
|
|
22
|
+
"gap": 20,
|
|
23
|
+
"keyW": 16,
|
|
24
|
+
"badgeSize": 9,
|
|
25
|
+
"badgePad": 4,
|
|
26
|
+
"badgeGap": 4,
|
|
27
|
+
"bottomPad": 4,
|
|
28
|
+
"minW": 120,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cache
|
|
33
|
+
def _metrics() -> dict:
|
|
34
|
+
return json.loads(resources.files("quickdbd_cli").joinpath("assets/font-metrics.json").read_text())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def text_width(text: str, size: float, bold: bool = False) -> float:
|
|
38
|
+
table = _metrics()["bold" if bold else "regular"]
|
|
39
|
+
return round(sum(table.get(ch, 0.6) for ch in text) * size, 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def badges(col: Column) -> list[str]:
|
|
43
|
+
out = []
|
|
44
|
+
if col.identity:
|
|
45
|
+
out.append("auto")
|
|
46
|
+
if col.unique:
|
|
47
|
+
out.append("unique")
|
|
48
|
+
if col.index:
|
|
49
|
+
out.append("index")
|
|
50
|
+
if col.nullable:
|
|
51
|
+
out.append("null")
|
|
52
|
+
if col.default is not None:
|
|
53
|
+
out.append(f"={col.default}")
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _column_json(col: Column) -> dict:
|
|
58
|
+
s = STYLE
|
|
59
|
+
bold = col.pk or col.fk
|
|
60
|
+
name_w = text_width(col.name, s["rowSize"], bold)
|
|
61
|
+
bs = [{"t": b, "w": round(text_width(b, s["badgeSize"]) + 2 * s["badgePad"], 2)} for b in badges(col)]
|
|
62
|
+
type_w = text_width(col.type or "", s["rowSize"])
|
|
63
|
+
return {
|
|
64
|
+
"id": col.id,
|
|
65
|
+
"name": col.name,
|
|
66
|
+
"type": col.type or "",
|
|
67
|
+
"pk": col.pk,
|
|
68
|
+
"fk": col.fk,
|
|
69
|
+
"nullable": col.nullable,
|
|
70
|
+
"unique": col.unique,
|
|
71
|
+
"index": col.index,
|
|
72
|
+
"identity": col.identity,
|
|
73
|
+
"default": col.default,
|
|
74
|
+
"nameW": name_w,
|
|
75
|
+
"typeW": type_w,
|
|
76
|
+
"badges": bs,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def table_json(table: Table) -> dict:
|
|
81
|
+
s = STYLE
|
|
82
|
+
cols = [_column_json(c) for c in table.columns.values() if not c.virtual]
|
|
83
|
+
w = text_width(table.name, s["headerSize"]) + 2 * s["padX"]
|
|
84
|
+
for c in cols:
|
|
85
|
+
row = c["nameW"] + c["typeW"] + 2 * s["padX"] + s["gap"]
|
|
86
|
+
row += sum(b["w"] + s["badgeGap"] for b in c["badges"])
|
|
87
|
+
if c["pk"]:
|
|
88
|
+
row += s["keyW"]
|
|
89
|
+
w = max(w, row)
|
|
90
|
+
w = max(round(w), s["minW"])
|
|
91
|
+
h = s["headerH"] + len(cols) * s["rowH"] + s["bottomPad"]
|
|
92
|
+
return {"id": table.id, "name": table.name, "aliases": table.aliases, "w": w, "h": h, "cols": cols}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def model_json(schema: Schema) -> dict:
|
|
96
|
+
"""Plain-JSON diagram model (no positions)."""
|
|
97
|
+
rels = []
|
|
98
|
+
for r in schema.valid_relationships():
|
|
99
|
+
rels.append(
|
|
100
|
+
{
|
|
101
|
+
"a": {"t": r.a.table, "c": r.a.column, "card": r.a.card},
|
|
102
|
+
"b": {"t": r.b.table, "c": r.b.column, "card": r.b.card},
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return {
|
|
106
|
+
"style": STYLE,
|
|
107
|
+
"tables": [table_json(t) for t in schema.tables.values()],
|
|
108
|
+
"rels": rels,
|
|
109
|
+
"zoom": schema.zoom,
|
|
110
|
+
"position": list(schema.position) if schema.position else None,
|
|
111
|
+
}
|
quickdbd_cli/model.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Data model shared by the parser, layout, and renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
VIRTUAL_FIELD = "qdbdvirtualfield"
|
|
8
|
+
|
|
9
|
+
# Relationship operator -> (cardinality of left member, cardinality of right member)
|
|
10
|
+
REL_TYPES: dict[str, tuple[str, str]] = {
|
|
11
|
+
"-": ("one", "one"),
|
|
12
|
+
"-<": ("one", "many"),
|
|
13
|
+
">-": ("many", "one"),
|
|
14
|
+
">-<": ("many", "many"),
|
|
15
|
+
"-0": ("one", "zeroOne"),
|
|
16
|
+
"0-": ("zeroOne", "one"),
|
|
17
|
+
"0-0": ("zeroOne", "zeroOne"),
|
|
18
|
+
"-0<": ("one", "zeroMany"),
|
|
19
|
+
">0-": ("zeroMany", "one"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Diagnostic:
|
|
25
|
+
path: str
|
|
26
|
+
line: int
|
|
27
|
+
message: str
|
|
28
|
+
severity: str = "error" # "error" | "warning"
|
|
29
|
+
|
|
30
|
+
def format(self) -> str:
|
|
31
|
+
return f"{self.path}:{self.line}: {self.severity}: {self.message}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Column:
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
line: int
|
|
39
|
+
order: int = 0
|
|
40
|
+
type: str | None = None
|
|
41
|
+
pk: bool = False
|
|
42
|
+
fk: bool = False
|
|
43
|
+
nullable: bool = False
|
|
44
|
+
unique: bool = False
|
|
45
|
+
index: bool = False
|
|
46
|
+
identity: bool = False
|
|
47
|
+
default: str | None = None
|
|
48
|
+
virtual: bool = False
|
|
49
|
+
comments: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Table:
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
path: str
|
|
57
|
+
line: int
|
|
58
|
+
aliases: list[str] = field(default_factory=list)
|
|
59
|
+
columns: dict[str, Column] = field(default_factory=dict)
|
|
60
|
+
comments: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class End:
|
|
65
|
+
table: str
|
|
66
|
+
column: str # column id, or VIRTUAL_FIELD for a table-level relationship
|
|
67
|
+
card: str = "one"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Relationship:
|
|
72
|
+
a: End
|
|
73
|
+
b: End
|
|
74
|
+
path: str
|
|
75
|
+
line: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Schema:
|
|
80
|
+
tables: dict[str, Table] = field(default_factory=dict)
|
|
81
|
+
relationships: list[Relationship] = field(default_factory=list)
|
|
82
|
+
zoom: float | None = None
|
|
83
|
+
position: tuple[float, float] | None = None
|
|
84
|
+
layout: dict[str, tuple[float, float]] = field(default_factory=dict)
|
|
85
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
86
|
+
general_comments: list[str] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def errors(self) -> list[Diagnostic]:
|
|
90
|
+
return [d for d in self.diagnostics if d.severity == "error"]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def warnings(self) -> list[Diagnostic]:
|
|
94
|
+
return [d for d in self.diagnostics if d.severity == "warning"]
|
|
95
|
+
|
|
96
|
+
def valid_relationships(self) -> list[Relationship]:
|
|
97
|
+
"""Relationships whose both ends resolve to existing tables/columns."""
|
|
98
|
+
out = []
|
|
99
|
+
for rel in self.relationships:
|
|
100
|
+
ok = True
|
|
101
|
+
for end in (rel.a, rel.b):
|
|
102
|
+
t = self.tables.get(end.table)
|
|
103
|
+
if t is None or end.column not in t.columns:
|
|
104
|
+
ok = False
|
|
105
|
+
if ok:
|
|
106
|
+
out.append(rel)
|
|
107
|
+
return out
|