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/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}
@@ -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