@caddis/cli 0.0.0 → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +150 -1
- package/bundles/antigravity-plugin/agents/code-reviewer.md +47 -0
- package/bundles/antigravity-plugin/agents/preflight.md +53 -0
- package/bundles/antigravity-plugin/guard_agy.py +338 -0
- package/bundles/antigravity-plugin/hooks.json +36 -0
- package/bundles/antigravity-plugin/mcp_config.json +8 -0
- package/bundles/antigravity-plugin/mcp_ping_server.py +55 -0
- package/bundles/antigravity-plugin/plugin.json +5 -0
- package/bundles/antigravity-plugin/session_end_agy.py +57 -0
- package/bundles/antigravity-plugin/skills/_registry.md +115 -0
- package/bundles/antigravity-plugin/skills/add-rules/SKILL.md +45 -0
- package/bundles/antigravity-plugin/skills/api-design/SKILL.md +249 -0
- package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +305 -0
- package/bundles/antigravity-plugin/skills/best-practices/SKILL.md +500 -0
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +263 -0
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +326 -0
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +245 -0
- package/bundles/antigravity-plugin/skills/best-practices/references/anti-patterns.md +571 -0
- package/bundles/antigravity-plugin/skills/best-practices/references/before-after-examples.md +1114 -0
- package/bundles/antigravity-plugin/skills/best-practices/references/best-practices-guide.md +513 -0
- package/bundles/antigravity-plugin/skills/best-practices/references/common-workflows.md +692 -0
- package/bundles/antigravity-plugin/skills/best-practices/references/prompt-patterns.md +547 -0
- package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +57 -0
- package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +315 -0
- package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +271 -0
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +122 -0
- package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +204 -0
- package/bundles/antigravity-plugin/skills/context-curator/SKILL.md +157 -0
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +40 -0
- package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +305 -0
- package/bundles/antigravity-plugin/skills/css-architecture/references/RESPONSIVE-DESIGN.md +604 -0
- package/bundles/antigravity-plugin/skills/database-design/SKILL.md +177 -0
- package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +148 -0
- package/bundles/antigravity-plugin/skills/db-diagram/scripts/sql_to_graph.py +1212 -0
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/draw-io/SKILL.md +162 -0
- package/bundles/antigravity-plugin/skills/draw-io/references/aws-icons.md +677 -0
- package/bundles/antigravity-plugin/skills/draw-io/references/layout-guidelines.md +142 -0
- package/bundles/antigravity-plugin/skills/draw-io/references/troubleshooting.md +118 -0
- package/bundles/antigravity-plugin/skills/draw-io/references/workflows.md +103 -0
- package/bundles/antigravity-plugin/skills/draw-io/scripts/convert-drawio-to-png.sh +25 -0
- package/bundles/antigravity-plugin/skills/draw-io/scripts/find_aws_icon.py +79 -0
- package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +260 -0
- package/bundles/antigravity-plugin/skills/excalidraw-db/SKILL.md +38 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +300 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +198 -0
- package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +193 -0
- package/bundles/antigravity-plugin/skills/frontend-design/references/my-tech-stack.md +127 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +195 -0
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +285 -0
- package/bundles/antigravity-plugin/skills/golden-plan/SKILL.md +577 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +100 -0
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +114 -0
- package/bundles/antigravity-plugin/skills/javascript-typescript/SKILL.md +142 -0
- package/bundles/antigravity-plugin/skills/kb/SKILL.md +60 -0
- package/bundles/antigravity-plugin/skills/mermaid-db/SKILL.md +33 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +236 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/ENHANCEMENTS.md +264 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/MERMAID-SUMMARY.md +137 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/advanced-features.md +556 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/architecture-diagrams.md +192 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/c4-diagrams.md +410 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/class-diagrams.md +361 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/erd-diagrams.md +510 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/flowcharts.md +450 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/sequence-diagrams.md +394 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/troubleshooting.md +335 -0
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/workflows.md +418 -0
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +68 -0
- package/bundles/antigravity-plugin/skills/mockup/SKILL.md +242 -0
- package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +243 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/canvas-utils.ts +171 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/dot-field.template.tsx +203 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/flow-field.template.tsx +263 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/node-shape.template.tsx +261 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/shape-sampler.ts +281 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/stipple-morph.template.tsx +167 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/stipple.template.tsx +175 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/trail-ghost.template.tsx +266 -0
- package/bundles/antigravity-plugin/skills/particle-art/references/usage-examples.md +320 -0
- package/bundles/antigravity-plugin/skills/playwright/API_REFERENCE.md +653 -0
- package/bundles/antigravity-plugin/skills/playwright/SKILL.md +454 -0
- package/bundles/antigravity-plugin/skills/playwright/lib/helpers.js +441 -0
- package/bundles/antigravity-plugin/skills/playwright/package.json +26 -0
- package/bundles/antigravity-plugin/skills/playwright/run.js +228 -0
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +107 -0
- package/bundles/antigravity-plugin/skills/preflight/SKILL.md +435 -0
- package/bundles/antigravity-plugin/skills/python/SKILL.md +388 -0
- package/bundles/antigravity-plugin/skills/react-best-practices/SKILL.md +269 -0
- package/bundles/antigravity-plugin/skills/react-dev/README.md +404 -0
- package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +459 -0
- package/bundles/antigravity-plugin/skills/react-dev/examples/generic-components.md +579 -0
- package/bundles/antigravity-plugin/skills/react-dev/examples/server-components.md +579 -0
- package/bundles/antigravity-plugin/skills/react-dev/references/event-handlers.md +574 -0
- package/bundles/antigravity-plugin/skills/react-dev/references/hooks.md +456 -0
- package/bundles/antigravity-plugin/skills/react-dev/references/react-19-patterns.md +638 -0
- package/bundles/antigravity-plugin/skills/react-dev/references/react-router.md +1002 -0
- package/bundles/antigravity-plugin/skills/react-dev/references/tanstack-router.md +587 -0
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +486 -0
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +36 -0
- package/bundles/antigravity-plugin/skills/security-review/SKILL.md +196 -0
- package/bundles/antigravity-plugin/skills/setup-project-ai/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +107 -0
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +103 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +102 -0
- package/bundles/antigravity-plugin/skills/skill-creator/LICENSE.txt +202 -0
- package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +491 -0
- package/bundles/antigravity-plugin/skills/skill-creator/agents/analyzer.md +274 -0
- package/bundles/antigravity-plugin/skills/skill-creator/agents/comparator.md +202 -0
- package/bundles/antigravity-plugin/skills/skill-creator/agents/grader.md +223 -0
- package/bundles/antigravity-plugin/skills/skill-creator/assets/eval_review.html +146 -0
- package/bundles/antigravity-plugin/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/bundles/antigravity-plugin/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/bundles/antigravity-plugin/skills/skill-creator/references/schemas.md +430 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/__init__.py +0 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/generate_report.py +326 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/improve_description.py +247 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/package_skill.py +136 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/run_eval.py +310 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/run_loop.py +328 -0
- package/bundles/antigravity-plugin/skills/skill-creator/scripts/utils.py +47 -0
- package/bundles/antigravity-plugin/skills/sql/SKILL.md +321 -0
- package/bundles/antigravity-plugin/skills/tdd/SKILL.md +37 -0
- package/bundles/antigravity-plugin/skills/tdd-workflow/SKILL.md +188 -0
- package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +286 -0
- package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +155 -0
- package/bundles/antigravity-plugin/skills/ui-brief/SKILL.md +84 -0
- package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +176 -0
- package/bundles/antigravity-plugin/skills/ui-review/references/framework-fixes.md +471 -0
- package/bundles/antigravity-plugin/skills/ui-review/references/visual-checklist.md +236 -0
- package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +77 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +64 -0
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +217 -0
- package/bundles/antigravity-plugin/skills/version/SKILL.md +18 -0
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/DESIGN_TOKENS.md +487 -0
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/IMPLEMENTATION_GUIDE.md +177 -0
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +732 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/LICENSE.txt +202 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +97 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/examples/console_logging.py +35 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/examples/element_discovery.py +40 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/examples/static_html_automation.py +33 -0
- package/bundles/antigravity-plugin/skills/webapp-testing/scripts/with_server.py +106 -0
- package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +880 -0
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +384 -0
- package/bundles/antigravity-plugin/statusline-command-agy.sh +91 -0
- package/bundles/antigravity-plugin/warm_start_agy.py +149 -0
- package/bundles/manifest.json +6 -0
- package/dist/cli.js +5363 -0
- package/package.json +61 -4
|
@@ -0,0 +1,1212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic SQL -> typed-graph + multi-format renderer (the db-diagram skill).
|
|
3
|
+
|
|
4
|
+
This is the DETERMINISTIC half of /mermaid-db and /excalidraw-db. It parses SQL with sqlglot
|
|
5
|
+
(a proper AST, dialect-aware) into a stable node/edge model, then renders it four ways — all from
|
|
6
|
+
ONE deterministic layout so the geometry is reproducible, never luck:
|
|
7
|
+
|
|
8
|
+
* Mermaid — git-diffable text; dagre auto-layout; light-first, adapts to the viewer's theme.
|
|
9
|
+
* Excalidraw — native .excalidraw JSON; container-bound (auto-wrapping, vertically-centred) text so
|
|
10
|
+
every label stays INSIDE its box; bound arrows; grid-aligned columns; theme "light".
|
|
11
|
+
* SVG — self-contained, no external refs; dual-theme via CSS custom props (default light).
|
|
12
|
+
* HTML — self-contained page wrapping the SVG with a light/dark toggle (default LIGHT).
|
|
13
|
+
|
|
14
|
+
The LLM narration layer wraps this output with business prose, per-table descriptions, and the
|
|
15
|
+
execution-plan caveat — but the STRUCTURE (which tables, CTEs, joins+keys, filters) and the GEOMETRY
|
|
16
|
+
(where every box sits, how wide, how the text wraps) come from here, deterministically.
|
|
17
|
+
|
|
18
|
+
Two diagram types are derived structurally:
|
|
19
|
+
* CREATE TABLE / DDL -> erDiagram (entities, columns, PK/FK, FK relationships)
|
|
20
|
+
* a query / proc / CTE -> flowchart (tables [(T)], CTEs {{CTE: name}}, joins with keys,
|
|
21
|
+
filters as distinct nodes, final projection [/TOP.. ORDER BY../])
|
|
22
|
+
|
|
23
|
+
sqlglot is required. If it's absent, ``analyze`` raises ``SqlglotUnavailable`` with an actionable
|
|
24
|
+
message — the skill then falls back to LLM hand-parsing from the SQL text (marking inferred).
|
|
25
|
+
|
|
26
|
+
READ-ONLY by construction: this only parses SQL text. It never connects to or writes a database.
|
|
27
|
+
|
|
28
|
+
CLI: python sql_to_graph.py --file query.sql [--dialect tsql] [--format mermaid|excalidraw|svg|html|json]
|
|
29
|
+
python sql_to_graph.py --sql "SELECT ..." (default output: the Mermaid block)
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import sys
|
|
36
|
+
from collections import deque
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
try: # sqlglot is required for structural extraction; absence is handled explicitly.
|
|
40
|
+
import sqlglot
|
|
41
|
+
from sqlglot import exp
|
|
42
|
+
|
|
43
|
+
_HAVE_SQLGLOT = True
|
|
44
|
+
except Exception: # noqa: BLE001
|
|
45
|
+
_HAVE_SQLGLOT = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SqlglotUnavailable(RuntimeError):
|
|
49
|
+
"""Raised when sqlglot isn't importable. Carries an actionable install hint."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sanitize_label(text: str) -> str:
|
|
53
|
+
"""Make a string safe inside a Mermaid node label: collapse whitespace and remove the
|
|
54
|
+
double-quotes / brackets that break Mermaid's parser (the #1 rendering footgun)."""
|
|
55
|
+
out = " ".join((text or "").split())
|
|
56
|
+
return out.replace('"', "'").replace("[", "(").replace("]", ")")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _node_id(prefix: str, name: str, seen: dict[str, int]) -> str:
|
|
60
|
+
base = "".join(c if c.isalnum() else "_" for c in name.lower()) or "x"
|
|
61
|
+
key = f"{prefix}_{base}"
|
|
62
|
+
seen[key] = seen.get(key, 0) + 1
|
|
63
|
+
return key if seen[key] == 1 else f"{key}_{seen[key]}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _wrap(text: str, max_chars: int) -> list[str]:
|
|
67
|
+
"""Greedy word-wrap into lines of at most ``max_chars`` (a word longer than the limit is
|
|
68
|
+
hard-split). This is what keeps every label INSIDE its box — box heights are computed from
|
|
69
|
+
the resulting line count, so wrapping and containment are two ends of the same guarantee."""
|
|
70
|
+
words = (text or "").split()
|
|
71
|
+
if not words:
|
|
72
|
+
return [""]
|
|
73
|
+
lines: list[str] = []
|
|
74
|
+
cur = ""
|
|
75
|
+
for w in words:
|
|
76
|
+
while len(w) > max_chars: # a single over-long token: hard-split it
|
|
77
|
+
if cur:
|
|
78
|
+
lines.append(cur)
|
|
79
|
+
cur = ""
|
|
80
|
+
lines.append(w[:max_chars])
|
|
81
|
+
w = w[max_chars:]
|
|
82
|
+
if not cur:
|
|
83
|
+
cur = w
|
|
84
|
+
elif len(cur) + 1 + len(w) <= max_chars:
|
|
85
|
+
cur = f"{cur} {w}"
|
|
86
|
+
else:
|
|
87
|
+
lines.append(cur)
|
|
88
|
+
cur = w
|
|
89
|
+
if cur:
|
|
90
|
+
lines.append(cur)
|
|
91
|
+
return lines
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── flowchart (query / proc) ──────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _analyze_query(ast, dialect: str) -> dict[str, Any]:
|
|
97
|
+
cte_names = [c.alias for c in ast.find_all(exp.CTE)]
|
|
98
|
+
cte_set = set(cte_names)
|
|
99
|
+
|
|
100
|
+
# Source tables = every referenced table whose name is NOT a CTE alias, first-seen order.
|
|
101
|
+
source_tables: list[str] = []
|
|
102
|
+
for t in ast.find_all(exp.Table):
|
|
103
|
+
name = t.name
|
|
104
|
+
if name and name not in cte_set and name not in source_tables:
|
|
105
|
+
source_tables.append(name)
|
|
106
|
+
|
|
107
|
+
joins: list[dict[str, str]] = []
|
|
108
|
+
for j in ast.find_all(exp.Join):
|
|
109
|
+
target = j.this.name if isinstance(j.this, exp.Table) else _sanitize_label(j.this.sql(dialect=dialect))
|
|
110
|
+
side = (j.args.get("side") or "").upper()
|
|
111
|
+
kind = (j.args.get("kind") or "").upper()
|
|
112
|
+
label = (f"{side} {kind}".strip()) or "INNER"
|
|
113
|
+
on = j.args.get("on")
|
|
114
|
+
joins.append({
|
|
115
|
+
"kind": label.split()[0] if label else "INNER",
|
|
116
|
+
"side_kind": label,
|
|
117
|
+
"target": target,
|
|
118
|
+
"on": _sanitize_label(on.sql(dialect=dialect)) if on else "",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
# Filters: every WHERE in the tree (the outer query AND any CTE/subquery), each split on
|
|
122
|
+
# top-level AND into distinct predicate nodes — so a filter inside a CTE still shows up.
|
|
123
|
+
filters: list[str] = []
|
|
124
|
+
for where in ast.find_all(exp.Where):
|
|
125
|
+
stack = [where.this]
|
|
126
|
+
parts: list[Any] = []
|
|
127
|
+
while stack:
|
|
128
|
+
node = stack.pop()
|
|
129
|
+
if isinstance(node, exp.And):
|
|
130
|
+
stack.extend([node.left, node.right])
|
|
131
|
+
else:
|
|
132
|
+
parts.append(node)
|
|
133
|
+
for p in reversed(parts): # preserve source order (stack reverses it)
|
|
134
|
+
frag = _sanitize_label(p.sql(dialect=dialect))
|
|
135
|
+
if frag not in filters:
|
|
136
|
+
filters.append(frag)
|
|
137
|
+
|
|
138
|
+
# Projection: TOP/LIMIT + ORDER BY, the "shape" of the final result.
|
|
139
|
+
proj_bits: list[str] = []
|
|
140
|
+
limit = ast.args.get("limit")
|
|
141
|
+
if limit is not None:
|
|
142
|
+
proj_bits.append(_sanitize_label(limit.sql(dialect=dialect)))
|
|
143
|
+
# T-SQL TOP lives on the SELECT as `expressions[0]`-adjacent; sqlglot exposes it via `.args['limit']`
|
|
144
|
+
# for LIMIT and a Top node for TOP — cover both.
|
|
145
|
+
for top in ast.find_all(exp.Select):
|
|
146
|
+
t = top.args.get("limit")
|
|
147
|
+
if isinstance(t, exp.Limit) and t.sql(dialect=dialect) not in proj_bits:
|
|
148
|
+
proj_bits.append(_sanitize_label(t.sql(dialect=dialect)))
|
|
149
|
+
order = ast.args.get("order")
|
|
150
|
+
if order is not None:
|
|
151
|
+
proj_bits.append(_sanitize_label(order.sql(dialect=dialect)))
|
|
152
|
+
projection = " . ".join(b for b in proj_bits if b) or None
|
|
153
|
+
|
|
154
|
+
# GROUP BY (+ HAVING) is its own pipeline stage — the Σ box between σ WHERE and the result.
|
|
155
|
+
aggregate = None
|
|
156
|
+
group = ast.args.get("group") if hasattr(ast, "args") else None
|
|
157
|
+
if group is not None:
|
|
158
|
+
aggregate = _sanitize_label(group.sql(dialect=dialect))
|
|
159
|
+
having = ast.args.get("having")
|
|
160
|
+
if having is not None:
|
|
161
|
+
aggregate += f" {_sanitize_label(having.sql(dialect=dialect))}"
|
|
162
|
+
|
|
163
|
+
# UNION / INTERSECT / EXCEPT: annotate the result (the branches' tables all feed it).
|
|
164
|
+
set_op = None
|
|
165
|
+
op_name = type(ast).__name__
|
|
166
|
+
if op_name in ("Union", "Except", "Intersect"):
|
|
167
|
+
set_op = {"Union": "UNION", "Except": "EXCEPT", "Intersect": "INTERSECT"}[op_name]
|
|
168
|
+
if op_name == "Union" and not ast.args.get("distinct", True):
|
|
169
|
+
set_op = "UNION ALL"
|
|
170
|
+
|
|
171
|
+
mermaid = _render_flowchart(source_tables, cte_names, joins, filters, projection,
|
|
172
|
+
aggregate=aggregate, set_op=set_op)
|
|
173
|
+
return {
|
|
174
|
+
"diagram_type": "flowchart",
|
|
175
|
+
"confidence": "high",
|
|
176
|
+
"inferred": [],
|
|
177
|
+
"source_tables": source_tables,
|
|
178
|
+
"ctes": cte_names,
|
|
179
|
+
"joins": joins,
|
|
180
|
+
"filters": filters,
|
|
181
|
+
"projection": projection,
|
|
182
|
+
"aggregate": aggregate,
|
|
183
|
+
"set_op": set_op,
|
|
184
|
+
"entities": [],
|
|
185
|
+
"relationships": [],
|
|
186
|
+
"mermaid": mermaid,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# Relational-algebra notation — the professional shorthand DB folk already read. Operations get
|
|
191
|
+
# symbols; tables stay clean (the name is the star).
|
|
192
|
+
# σ = selection (WHERE) · Σ = aggregation (GROUP BY) · π = projection (TOP/ORDER)
|
|
193
|
+
# ⋈ = join · ρ = rename (a CTE) · ∪/∩/∖ = set operations
|
|
194
|
+
_SET_OP_SYM = {"UNION": "∪", "UNION ALL": "∪", "INTERSECT": "∩", "EXCEPT": "∖"}
|
|
195
|
+
|
|
196
|
+
# Mermaid classDef fills — light-first, but chosen to stay legible if the viewer flips to dark
|
|
197
|
+
# (Mermaid re-tints on the viewer's theme; these are the base hues, matching the SVG/Excalidraw palette).
|
|
198
|
+
# Palette "jewel on ivory": harbor teal / plum / saffron / madder / ink-blue / viridian on warm ivory —
|
|
199
|
+
# a deliberate identity, not the default pastel set every generated diagram ships with.
|
|
200
|
+
_MERMAID_CLASSDEF = {
|
|
201
|
+
"table": "fill:#EDF7F6,stroke:#0E7C7B,color:#073938",
|
|
202
|
+
"cte": "fill:#F4EEF8,stroke:#7B4B94,color:#3A2149",
|
|
203
|
+
"filter": "fill:#FBF1E3,stroke:#C36F09,color:#5E3604",
|
|
204
|
+
"aggregate": "fill:#F9ECEE,stroke:#B23A48,color:#571C24",
|
|
205
|
+
"result": "fill:#EBF1FA,stroke:#2D5DA1,color:#16294D",
|
|
206
|
+
"projection": "fill:#EDF5F0,stroke:#4A7C59,color:#1F3A29",
|
|
207
|
+
}
|
|
208
|
+
_MERMAID_WRAP = 26 # chars before inserting <br/> in a node label (keeps text inside the box)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _mwrap(text: str) -> str:
|
|
212
|
+
"""Wrap a Mermaid node label with <br/> so long filters/projections don't overflow the box."""
|
|
213
|
+
return "<br/>".join(_wrap(text, _MERMAID_WRAP))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _render_flowchart(source_tables, ctes, joins, filters, projection,
|
|
217
|
+
aggregate=None, set_op=None) -> str:
|
|
218
|
+
# PIPELINE rule: sources/CTEs -> σ WHERE -> Σ GROUP BY -> result -> π projection.
|
|
219
|
+
# Every edge connects ADJACENT stages only — an arrow can never cross a box, and there is
|
|
220
|
+
# exactly one fan-in point (the first stage present, or the result).
|
|
221
|
+
seen: dict[str, int] = {}
|
|
222
|
+
lines = ["flowchart LR"]
|
|
223
|
+
ids: dict[str, str] = {}
|
|
224
|
+
classes: dict[str, list[str]] = {k: [] for k in _MERMAID_CLASSDEF}
|
|
225
|
+
order: list[str] = [] # node keys in display order (drives edge order too)
|
|
226
|
+
for name in source_tables:
|
|
227
|
+
nid = _node_id("t", name, seen)
|
|
228
|
+
ids[f"table:{name}"] = nid
|
|
229
|
+
lines.append(f' {nid}[({name})]')
|
|
230
|
+
classes["table"].append(nid)
|
|
231
|
+
order.append(f"table:{name}")
|
|
232
|
+
for name in ctes:
|
|
233
|
+
nid = _node_id("cte", name, seen)
|
|
234
|
+
ids[f"cte:{name}"] = nid
|
|
235
|
+
lines.append(f' {nid}{{{{"ρ CTE: {name}"}}}}')
|
|
236
|
+
classes["cte"].append(nid)
|
|
237
|
+
order.append(f"cte:{name}")
|
|
238
|
+
# join labels by target; a join target that isn't a plain source (e.g. a subquery) still
|
|
239
|
+
# gets its own node so its label has somewhere to hang.
|
|
240
|
+
join_label: dict[str, str] = {}
|
|
241
|
+
for j in joins:
|
|
242
|
+
on = f" on {j['on']}" if j["on"] else ""
|
|
243
|
+
join_label[j["target"]] = f'⋈ {j["side_kind"]} JOIN{on}'
|
|
244
|
+
if not (ids.get(f"table:{j['target']}") or ids.get(f"cte:{j['target']}")):
|
|
245
|
+
nid = _node_id("t", j["target"], seen)
|
|
246
|
+
ids[f"table:{j['target']}"] = nid
|
|
247
|
+
lines.append(f' {nid}[({j["target"]})]')
|
|
248
|
+
classes["table"].append(nid)
|
|
249
|
+
order.append(f"table:{j['target']}")
|
|
250
|
+
# the operation chain: σ WHERE → Σ GROUP BY → result (only the stages the query has)
|
|
251
|
+
chain: list[str] = []
|
|
252
|
+
if filters:
|
|
253
|
+
parts = [_mwrap(("σ WHERE " if i == 0 else "AND ") + f) for i, f in enumerate(filters)]
|
|
254
|
+
lines.append(f' where["{"<br/>".join(parts)}"]')
|
|
255
|
+
classes["filter"].append("where")
|
|
256
|
+
chain.append("where")
|
|
257
|
+
if aggregate:
|
|
258
|
+
lines.append(f' agg["{_mwrap("Σ " + aggregate)}"]')
|
|
259
|
+
classes["aggregate"].append("agg")
|
|
260
|
+
chain.append("agg")
|
|
261
|
+
result_label = "result" if not set_op else f"{_SET_OP_SYM[set_op]} {set_op} result"
|
|
262
|
+
lines.append(f' result[/"{result_label}"/]')
|
|
263
|
+
classes["result"].append("result")
|
|
264
|
+
chain.append("result")
|
|
265
|
+
hub = chain[0]
|
|
266
|
+
for key in order:
|
|
267
|
+
name = key.split(":", 1)[1]
|
|
268
|
+
lbl = join_label.get(name, "")
|
|
269
|
+
edge = f'-->|"{lbl}"|' if lbl else "-->"
|
|
270
|
+
lines.append(f' {ids[key]} {edge} {hub}')
|
|
271
|
+
for a, b in zip(chain, chain[1:]):
|
|
272
|
+
lines.append(f" {a} --> {b}")
|
|
273
|
+
if projection:
|
|
274
|
+
lines.append(f' result --> proj[/"{_mwrap("π " + projection)}"/]')
|
|
275
|
+
classes["projection"].append("proj")
|
|
276
|
+
# classDefs + assignments (colour the typed nodes; harmless if a viewer strips styling)
|
|
277
|
+
for role, style in _MERMAID_CLASSDEF.items():
|
|
278
|
+
lines.append(f" classDef {role} {style};")
|
|
279
|
+
for role, nids in classes.items():
|
|
280
|
+
if nids:
|
|
281
|
+
lines.append(f" class {','.join(nids)} {role};")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ── erDiagram (DDL) ───────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _analyze_ddl(statements, dialect: str) -> dict[str, Any]:
|
|
288
|
+
entities: list[dict[str, Any]] = []
|
|
289
|
+
relationships: list[dict[str, str]] = []
|
|
290
|
+
for stmt in statements:
|
|
291
|
+
if not isinstance(stmt, exp.Create):
|
|
292
|
+
continue
|
|
293
|
+
schema = stmt.this
|
|
294
|
+
if not isinstance(schema, exp.Schema) or not isinstance(schema.this, exp.Table):
|
|
295
|
+
continue
|
|
296
|
+
tname = schema.this.name
|
|
297
|
+
columns: list[dict[str, str]] = []
|
|
298
|
+
pk_cols: set[str] = set()
|
|
299
|
+
for col in schema.expressions:
|
|
300
|
+
if isinstance(col, exp.ColumnDef):
|
|
301
|
+
cname = col.name
|
|
302
|
+
ctype = col.args.get("kind").sql(dialect=dialect) if col.args.get("kind") else ""
|
|
303
|
+
key = ""
|
|
304
|
+
for c in col.args.get("constraints", []) or []:
|
|
305
|
+
ck = c.kind if hasattr(c, "kind") else None
|
|
306
|
+
if isinstance(ck, exp.PrimaryKeyColumnConstraint):
|
|
307
|
+
key = "PK"
|
|
308
|
+
pk_cols.add(cname)
|
|
309
|
+
columns.append({"name": cname, "type": _sanitize_label(ctype), "key": key})
|
|
310
|
+
elif isinstance(col, exp.PrimaryKey):
|
|
311
|
+
for e in col.expressions:
|
|
312
|
+
pk_cols.add(e.name)
|
|
313
|
+
elif isinstance(col, exp.ForeignKey):
|
|
314
|
+
local_cols = [e.name for e in col.expressions]
|
|
315
|
+
ref = col.args.get("reference")
|
|
316
|
+
ref_table = None
|
|
317
|
+
if ref is not None:
|
|
318
|
+
rt = ref.find(exp.Table)
|
|
319
|
+
ref_table = rt.name if rt else None
|
|
320
|
+
if ref_table:
|
|
321
|
+
relationships.append({
|
|
322
|
+
"from": tname, "to": ref_table,
|
|
323
|
+
"on": ", ".join(local_cols),
|
|
324
|
+
})
|
|
325
|
+
# backfill PK flag for columns named in a table-level PRIMARY KEY
|
|
326
|
+
for c in columns:
|
|
327
|
+
if c["name"] in pk_cols and not c["key"]:
|
|
328
|
+
c["key"] = "PK"
|
|
329
|
+
# mark FK columns
|
|
330
|
+
fk_cols = {lc for r in relationships if r["from"] == tname for lc in r["on"].split(", ")}
|
|
331
|
+
for c in columns:
|
|
332
|
+
if c["name"] in fk_cols and c["key"] != "PK":
|
|
333
|
+
c["key"] = "FK"
|
|
334
|
+
entities.append({"name": tname, "columns": columns})
|
|
335
|
+
|
|
336
|
+
mermaid = _render_erdiagram(entities, relationships)
|
|
337
|
+
return {
|
|
338
|
+
"diagram_type": "erDiagram",
|
|
339
|
+
"confidence": "high",
|
|
340
|
+
"inferred": [],
|
|
341
|
+
"source_tables": [e["name"] for e in entities],
|
|
342
|
+
"ctes": [],
|
|
343
|
+
"joins": [],
|
|
344
|
+
"filters": [],
|
|
345
|
+
"projection": None,
|
|
346
|
+
"aggregate": None,
|
|
347
|
+
"set_op": None,
|
|
348
|
+
"entities": entities,
|
|
349
|
+
"relationships": relationships,
|
|
350
|
+
"mermaid": mermaid,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _render_erdiagram(entities, relationships) -> str:
|
|
355
|
+
lines = ["erDiagram"]
|
|
356
|
+
for r in relationships:
|
|
357
|
+
# child }o--|| parent (many optional children to exactly one parent)
|
|
358
|
+
lines.append(f' {r["to"].upper()} ||--o{{ {r["from"].upper()} : "{r["on"]}"')
|
|
359
|
+
for e in entities:
|
|
360
|
+
lines.append(f' {e["name"].upper()} {{')
|
|
361
|
+
for c in e["columns"]:
|
|
362
|
+
typ = c["type"] or "unknown"
|
|
363
|
+
key = f" {c['key']}" if c["key"] else ""
|
|
364
|
+
lines.append(f' {typ} {c["name"]}{key}')
|
|
365
|
+
lines.append(" }")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── deterministic geometry (shared by Excalidraw / SVG / HTML) ─────────────────
|
|
370
|
+
#
|
|
371
|
+
# ONE layout pass positions every node on a grid: same-column nodes share an x (perfect vertical
|
|
372
|
+
# alignment); every column is centred on the same mid-line (balanced); box heights are computed from
|
|
373
|
+
# the WRAPPED line count (text always fits). Every renderer below just reads these coordinates — so
|
|
374
|
+
# alignment and containment are reproducible properties of the layout, not per-format luck.
|
|
375
|
+
|
|
376
|
+
_FONT = 16 # node label font size (px)
|
|
377
|
+
_PAD_H = 12 # horizontal text padding inside a box
|
|
378
|
+
_PAD_V = 12 # vertical text padding inside a box
|
|
379
|
+
_MIN_H = 46 # minimum box height
|
|
380
|
+
_COL_GAP = 88 # horizontal gap between columns
|
|
381
|
+
_ROW_GAP = 26 # vertical gap between boxes in a column
|
|
382
|
+
_MARGIN = 36 # canvas margin
|
|
383
|
+
_CHAR_W = _FONT * 0.55 # approx glyph advance for wrap-width math
|
|
384
|
+
|
|
385
|
+
# target box width per role (px) — wrap width derives from this, so text is guaranteed to fit.
|
|
386
|
+
_ROLE_W = {"table": 200, "cte": 200, "filter": 250, "aggregate": 250, "result": 170,
|
|
387
|
+
"projection": 230, "entity": 240}
|
|
388
|
+
_EDGE_LABEL_WRAP = 28 # chars per line for a join label riding beside its arrow
|
|
389
|
+
_EDGE_LABEL_FONT = 12 # edge label font size (px)
|
|
390
|
+
# fixed Excalidraw palette (the app re-tints for dark itself; these are the light/base hues) —
|
|
391
|
+
# the same "jewel on ivory" family as Mermaid + SVG.
|
|
392
|
+
_EXCALI = {
|
|
393
|
+
"table": ("#EDF7F6", "#0E7C7B"), "cte": ("#F4EEF8", "#7B4B94"),
|
|
394
|
+
"filter": ("#FBF1E3", "#C36F09"), "aggregate": ("#F9ECEE", "#B23A48"),
|
|
395
|
+
"result": ("#EBF1FA", "#2D5DA1"), "projection": ("#EDF5F0", "#4A7C59"),
|
|
396
|
+
"entity": ("#EDF7F6", "#0E7C7B"),
|
|
397
|
+
}
|
|
398
|
+
_EXCALI_INK = "#1C2431"
|
|
399
|
+
_EDGE_COLOR = "#5B6472"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _edge_label_size(label: str) -> tuple[list[str], float, float]:
|
|
403
|
+
"""Wrapped lines + pixel (w, h) of an edge label block."""
|
|
404
|
+
lines = _wrap(label, _EDGE_LABEL_WRAP)
|
|
405
|
+
w = round(max(len(ln) for ln in lines) * _EDGE_LABEL_FONT * 0.55 + 8, 2)
|
|
406
|
+
h = len(lines) * (_EDGE_LABEL_FONT + 3)
|
|
407
|
+
return lines, w, h
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _role_max_chars(role: str) -> int:
|
|
411
|
+
return max(6, int((_ROLE_W[role] - 2 * _PAD_H) / _CHAR_W))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _mk_node(nid: str, role: str, label: str, *, sub: str | None = None,
|
|
415
|
+
align: str = "center", font: int = _FONT) -> dict[str, Any]:
|
|
416
|
+
"""Build a laid-out node with wrapped lines + a box tall enough to contain them.
|
|
417
|
+
``sub`` adds a secondary info block under the title (e.g. a table's join condition) —
|
|
418
|
+
inside the box, where containment is guaranteed, instead of floating in the diagram."""
|
|
419
|
+
max_chars = _role_max_chars(role)
|
|
420
|
+
lines = _wrap(label, max_chars)
|
|
421
|
+
if sub:
|
|
422
|
+
lines.append("") # breathing room between title and sub-line
|
|
423
|
+
lines.extend(_wrap(sub, max_chars))
|
|
424
|
+
line_px = int(font * 1.25)
|
|
425
|
+
h = max(_MIN_H, len(lines) * line_px + 2 * _PAD_V)
|
|
426
|
+
return {"id": nid, "role": role, "lines": lines, "text": "\n".join(lines),
|
|
427
|
+
"w": _ROLE_W[role], "h": h, "font": font, "align": align, "line_px": line_px,
|
|
428
|
+
"x": 0.0, "y": 0.0}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _place_columns(columns: list[list[dict[str, Any]]],
|
|
432
|
+
gaps: list[float] | None = None) -> dict[str, Any]:
|
|
433
|
+
"""Position a list of columns left-to-right; centre each column on a shared mid-line.
|
|
434
|
+
``gaps[i]`` overrides the gap AFTER column i — used to widen a gap that must also hold
|
|
435
|
+
edge labels, so a label never has to sit on top of a box or an arrow."""
|
|
436
|
+
col_heights = [sum(n["h"] for n in col) + _ROW_GAP * max(0, len(col) - 1) for col in columns]
|
|
437
|
+
canvas_h = max(col_heights) if col_heights else 0
|
|
438
|
+
mid = _MARGIN + canvas_h / 2
|
|
439
|
+
x = float(_MARGIN)
|
|
440
|
+
nodes: list[dict[str, Any]] = []
|
|
441
|
+
last_gap = _COL_GAP
|
|
442
|
+
for idx, col in enumerate(columns):
|
|
443
|
+
if not col:
|
|
444
|
+
continue
|
|
445
|
+
col_w = max(n["w"] for n in col)
|
|
446
|
+
total = sum(n["h"] for n in col) + _ROW_GAP * (len(col) - 1)
|
|
447
|
+
y = mid - total / 2
|
|
448
|
+
for n in col:
|
|
449
|
+
n["x"] = x + (col_w - n["w"]) / 2 # centre the box within its column band
|
|
450
|
+
n["y"] = y
|
|
451
|
+
y += n["h"] + _ROW_GAP
|
|
452
|
+
nodes.append(n)
|
|
453
|
+
last_gap = gaps[idx] if gaps and idx < len(gaps) else _COL_GAP
|
|
454
|
+
x += col_w + last_gap
|
|
455
|
+
width = x - last_gap + _MARGIN if nodes else 2 * _MARGIN
|
|
456
|
+
height = canvas_h + 2 * _MARGIN
|
|
457
|
+
return {"nodes": nodes, "width": round(width), "height": round(height)}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _layout(graph: dict[str, Any]) -> dict[str, Any]:
|
|
461
|
+
"""Turn an ``analyze`` result into positioned nodes + edges (the geometry every renderer reads)."""
|
|
462
|
+
if graph.get("diagram_type") == "erDiagram":
|
|
463
|
+
return _layout_er(graph)
|
|
464
|
+
return _layout_flowchart(graph)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _annotate_fan_in(edges: list[dict[str, Any]]) -> None:
|
|
468
|
+
"""Stamp each edge with its slot among edges sharing a destination, so renderers can spread
|
|
469
|
+
arrowheads along the target's edge instead of piling them onto one point."""
|
|
470
|
+
by_dst: dict[str, list[dict[str, Any]]] = {}
|
|
471
|
+
for e in edges:
|
|
472
|
+
by_dst.setdefault(e["dst"], []).append(e)
|
|
473
|
+
for group in by_dst.values():
|
|
474
|
+
for i, e in enumerate(group):
|
|
475
|
+
e["slot"], e["slots"] = i, len(group)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _mk_where_node(filters: list[str]) -> dict[str, Any]:
|
|
479
|
+
"""ONE box for all ANDed predicates (one per line, wrapped) — three stacked filter boxes read
|
|
480
|
+
as three alternative paths; one σ WHERE box reads as what it is: a single conjunctive gate."""
|
|
481
|
+
lines: list[str] = []
|
|
482
|
+
for i, f in enumerate(filters):
|
|
483
|
+
lines.extend(_wrap(("σ WHERE " if i == 0 else "AND ") + f, _role_max_chars("filter")))
|
|
484
|
+
line_px = int(_FONT * 1.25)
|
|
485
|
+
h = max(_MIN_H, len(lines) * line_px + 2 * _PAD_V)
|
|
486
|
+
return {"id": "where", "role": "filter", "lines": lines, "text": "\n".join(lines),
|
|
487
|
+
"w": _ROLE_W["filter"], "h": h, "font": _FONT, "align": "left",
|
|
488
|
+
"line_px": line_px, "x": 0.0, "y": 0.0}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _layout_flowchart(graph: dict[str, Any]) -> dict[str, Any]:
|
|
492
|
+
# PIPELINE rule (same as the Mermaid renderer): sources -> WHERE -> result -> projection,
|
|
493
|
+
# edges between ADJACENT stages only. Geometrically that means no arrow can ever cross a box:
|
|
494
|
+
# the gap between two columns contains nothing but the arrows that bridge exactly those columns.
|
|
495
|
+
# Join info lives INSIDE each source box (a "⋈ …" sub-line) — flowchart arrows carry NO
|
|
496
|
+
# text. In a converging fan the space beside arrow N is occupied by arrow N±1, so any
|
|
497
|
+
# floating label will eventually cover some arrow; inside the box, containment is already
|
|
498
|
+
# guaranteed and every arrow stays fully visible.
|
|
499
|
+
join_label: dict[str, str] = {}
|
|
500
|
+
for j in graph.get("joins", []):
|
|
501
|
+
on = f" on {j['on']}" if j["on"] else ""
|
|
502
|
+
join_label[j["target"]] = f"⋈ {j['side_kind']} JOIN{on}"
|
|
503
|
+
|
|
504
|
+
seen: dict[str, int] = {}
|
|
505
|
+
by_name: dict[str, dict[str, Any]] = {}
|
|
506
|
+
sources_col: list[dict[str, Any]] = []
|
|
507
|
+
for name in graph.get("source_tables", []):
|
|
508
|
+
n = _mk_node(_node_id("t", name, seen), "table", name, sub=join_label.get(name))
|
|
509
|
+
by_name[f"table:{name}"] = n
|
|
510
|
+
sources_col.append(n)
|
|
511
|
+
for name in graph.get("ctes", []):
|
|
512
|
+
n = _mk_node(_node_id("cte", name, seen), "cte", f"ρ CTE: {name}", sub=join_label.get(name))
|
|
513
|
+
by_name[f"cte:{name}"] = n
|
|
514
|
+
sources_col.append(n)
|
|
515
|
+
|
|
516
|
+
filters = graph.get("filters", [])
|
|
517
|
+
where_col = [_mk_where_node(filters)] if filters else []
|
|
518
|
+
aggregate = graph.get("aggregate")
|
|
519
|
+
agg_col = [_mk_node("agg", "aggregate", f"Σ {aggregate}", align="left")] if aggregate else []
|
|
520
|
+
set_op = graph.get("set_op")
|
|
521
|
+
result = _mk_node("result", "result", "result",
|
|
522
|
+
sub=f"{_SET_OP_SYM[set_op]} {set_op}" if set_op else None)
|
|
523
|
+
proj = graph.get("projection")
|
|
524
|
+
proj_col = [_mk_node("proj", "projection", f"π {proj}")] if proj else []
|
|
525
|
+
|
|
526
|
+
placed = _place_columns([sources_col, where_col, agg_col, [result], proj_col])
|
|
527
|
+
|
|
528
|
+
# the operation chain (only the stages this query has), then chain edges through it
|
|
529
|
+
chain = [c[0] for c in (where_col, agg_col) if c] + [result]
|
|
530
|
+
edges: list[dict[str, Any]] = []
|
|
531
|
+
for n_key in ([f"table:{t}" for t in graph.get("source_tables", [])]
|
|
532
|
+
+ [f"cte:{c}" for c in graph.get("ctes", [])]):
|
|
533
|
+
edges.append({"src": by_name[n_key]["id"], "dst": chain[0]["id"], "label": ""})
|
|
534
|
+
for a, b in zip(chain, chain[1:]):
|
|
535
|
+
edges.append({"src": a["id"], "dst": b["id"], "label": ""})
|
|
536
|
+
if proj_col:
|
|
537
|
+
edges.append({"src": result["id"], "dst": proj_col[0]["id"], "label": ""})
|
|
538
|
+
_annotate_fan_in(edges)
|
|
539
|
+
|
|
540
|
+
placed["edges"] = edges
|
|
541
|
+
return placed
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ── ER geometry (single column + orthogonal left-channel routing) ──────────────
|
|
545
|
+
#
|
|
546
|
+
# Why not a grid + straight arrows? On a hub topology (one entity referenced by several, or a
|
|
547
|
+
# chain A→B→C, or a self-ref) a straight src→dst arrow runs BACKWARDS through the boxes between
|
|
548
|
+
# them — and under-painted, it hides entirely behind them. The fix that holds for ANY schema:
|
|
549
|
+
# stack entities in one vertical column and route every FK as an orthogonal elbow through a
|
|
550
|
+
# channel to the LEFT of the column. Every arrow segment lives at x ≤ (column's left edge), so it
|
|
551
|
+
# provably can never enter a non-endpoint box — the space it travels through contains no boxes at
|
|
552
|
+
# all. Each edge gets its own vertical lane (no two verticals overlap); ports along a box's left
|
|
553
|
+
# edge are spread so fan-in / fan-out arrows never pile onto one point. Fully deterministic.
|
|
554
|
+
_ER_W = 264 # entity box width
|
|
555
|
+
_ER_PAD_H = 14 # horizontal text padding
|
|
556
|
+
_ER_PAD_V = 12 # vertical text padding
|
|
557
|
+
_ER_HDR_FONT = 15 # entity-name header font (distinct from columns)
|
|
558
|
+
_ER_COL_FONT = 13 # column-line font
|
|
559
|
+
_ER_HDR_LINE = 20 # header line height
|
|
560
|
+
_ER_COL_LINE = 19 # column line height
|
|
561
|
+
_ER_DIV_GAP = 14 # gap reserved for the header divider rule
|
|
562
|
+
_ER_ROW_GAP = 46 # vertical gap between stacked entities (room for edge stubs)
|
|
563
|
+
_ER_LANE_STEP = 24 # horizontal spacing between adjacent edge lanes in the channel
|
|
564
|
+
_ER_CHANNEL_GAP = 30 # gap between the innermost lane and the boxes' left edge
|
|
565
|
+
_ER_PORT_INSET = 14 # keep ports off the box corners
|
|
566
|
+
_ER_BULLET_INDENT = 14 # x-indent for wrapped column continuation lines
|
|
567
|
+
_ER_DIV_LIGHT = "#D8DBE0" # divider rule colour in the (single-theme) Excalidraw scene
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _er_char_max(width: float, font: int, *, indent: float = 0.0) -> int:
|
|
571
|
+
return max(6, int((width - 2 * _ER_PAD_H - indent) / (font * 0.55)))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _fmt_er_column(c: dict[str, str]) -> str:
|
|
575
|
+
typ = c.get("type") or "?"
|
|
576
|
+
key = f" · {c['key']}" if c.get("key") else ""
|
|
577
|
+
return f"{c['name']} {typ}{key}"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _build_er_node(nid: str, entity: dict[str, Any]) -> dict[str, Any]:
|
|
581
|
+
"""An entity box carries a distinct HEADER (name, bold/larger/coloured, divider beneath) and a
|
|
582
|
+
BULLET-LIST of columns — kept as separate line groups so the renderers can style them apart
|
|
583
|
+
(a single container-bound text can't: it's one style)."""
|
|
584
|
+
hdr_max = _er_char_max(_ER_W, _ER_HDR_FONT)
|
|
585
|
+
col_max = _er_char_max(_ER_W, _ER_COL_FONT, indent=_ER_BULLET_INDENT)
|
|
586
|
+
header_lines = _wrap(entity["name"], hdr_max)
|
|
587
|
+
col_lines: list[dict[str, Any]] = []
|
|
588
|
+
for c in entity.get("columns", []):
|
|
589
|
+
for j, part in enumerate(_wrap(_fmt_er_column(c), col_max)):
|
|
590
|
+
col_lines.append({"text": part, "first": j == 0})
|
|
591
|
+
if not col_lines:
|
|
592
|
+
col_lines.append({"text": "(no columns)", "first": True})
|
|
593
|
+
h = float(_ER_PAD_V + len(header_lines) * _ER_HDR_LINE + _ER_DIV_GAP
|
|
594
|
+
+ len(col_lines) * _ER_COL_LINE + _ER_PAD_V)
|
|
595
|
+
return {"id": nid, "role": "entity", "header_lines": header_lines, "col_lines": col_lines,
|
|
596
|
+
"w": _ER_W, "h": h, "x": 0.0, "y": 0.0}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _er_order(entities: list[dict[str, Any]], rels: list[dict[str, str]]) -> list[str]:
|
|
600
|
+
"""Stack order: start at the hub (highest FK degree, ties → declaration order) and BFS out, so
|
|
601
|
+
related tables sit adjacent and their routing lanes stay short. Deterministic."""
|
|
602
|
+
names = [e["name"] for e in entities]
|
|
603
|
+
if not names:
|
|
604
|
+
return []
|
|
605
|
+
adj: dict[str, list[str]] = {n: [] for n in names}
|
|
606
|
+
for r in rels:
|
|
607
|
+
adj[r["from"]].append(r["to"])
|
|
608
|
+
adj[r["to"]].append(r["from"])
|
|
609
|
+
hub = max(names, key=lambda nm: (len(adj[nm]), -names.index(nm)))
|
|
610
|
+
order: list[str] = []
|
|
611
|
+
visited: set[str] = set()
|
|
612
|
+
dq = deque([hub])
|
|
613
|
+
while dq:
|
|
614
|
+
cur = dq.popleft()
|
|
615
|
+
if cur in visited:
|
|
616
|
+
continue
|
|
617
|
+
visited.add(cur)
|
|
618
|
+
order.append(cur)
|
|
619
|
+
for nb in adj[cur]: # neighbours in declaration order (adjacency is stable)
|
|
620
|
+
if nb not in visited:
|
|
621
|
+
dq.append(nb)
|
|
622
|
+
for nm in names: # any component not reachable from the hub
|
|
623
|
+
if nm not in visited:
|
|
624
|
+
visited.add(nm)
|
|
625
|
+
order.append(nm)
|
|
626
|
+
return order
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _layout_er(graph: dict[str, Any]) -> dict[str, Any]:
|
|
630
|
+
"""Entities stacked in one column; FK relationships routed as orthogonal elbows through a
|
|
631
|
+
left-side channel so no arrow can ever cross a non-endpoint box (see the note above)."""
|
|
632
|
+
entities = graph.get("entities", [])
|
|
633
|
+
seen: dict[str, int] = {}
|
|
634
|
+
node_by_name: dict[str, dict[str, Any]] = {}
|
|
635
|
+
nodes: list[dict[str, Any]] = []
|
|
636
|
+
for e in entities:
|
|
637
|
+
n = _build_er_node(_node_id("e", e["name"], seen), e)
|
|
638
|
+
nodes.append(n)
|
|
639
|
+
node_by_name[e["name"]] = n
|
|
640
|
+
|
|
641
|
+
rels = [r for r in graph.get("relationships", [])
|
|
642
|
+
if r["from"] in node_by_name and r["to"] in node_by_name]
|
|
643
|
+
|
|
644
|
+
# stack the boxes; leave a channel on the left wide enough for one lane per edge
|
|
645
|
+
n_lanes = max(1, len(rels))
|
|
646
|
+
box_x = float(_MARGIN + _ER_CHANNEL_GAP + n_lanes * _ER_LANE_STEP)
|
|
647
|
+
y = float(_MARGIN)
|
|
648
|
+
for nm in _er_order(entities, rels):
|
|
649
|
+
n = node_by_name[nm]
|
|
650
|
+
n["x"], n["y"] = box_x, y
|
|
651
|
+
y += n["h"] + _ER_ROW_GAP
|
|
652
|
+
total_h = (y - _ER_ROW_GAP + _MARGIN) if node_by_name else 2 * _MARGIN
|
|
653
|
+
|
|
654
|
+
# ports: every edge touching a box gets a distinct point spread along that box's left edge,
|
|
655
|
+
# ordered by edge index for determinism (so fan-in/fan-out arrows never share a point).
|
|
656
|
+
ports: dict[str, list[tuple[str, int]]] = {nm: [] for nm in node_by_name}
|
|
657
|
+
for idx, r in enumerate(rels):
|
|
658
|
+
ports[r["from"]].append(("out", idx))
|
|
659
|
+
ports[r["to"]].append(("in", idx))
|
|
660
|
+
port_y: dict[tuple[str, int, str], float] = {}
|
|
661
|
+
for nm, plist in ports.items():
|
|
662
|
+
b = node_by_name[nm]
|
|
663
|
+
m = len(plist)
|
|
664
|
+
top = b["y"] + _ER_PORT_INSET
|
|
665
|
+
usable = b["h"] - 2 * _ER_PORT_INSET
|
|
666
|
+
for i, (role, idx) in enumerate(sorted(plist, key=lambda t: t[1])):
|
|
667
|
+
port_y[(nm, idx, role)] = top + (usable * (i + 0.5) / m if m else usable / 2)
|
|
668
|
+
|
|
669
|
+
edges: list[dict[str, Any]] = []
|
|
670
|
+
for idx, r in enumerate(rels):
|
|
671
|
+
s, d = node_by_name[r["from"]], node_by_name[r["to"]]
|
|
672
|
+
sy = port_y[(r["from"], idx, "out")]
|
|
673
|
+
ey = port_y[(r["to"], idx, "in")]
|
|
674
|
+
lane_x = box_x - _ER_CHANNEL_GAP - idx * _ER_LANE_STEP
|
|
675
|
+
# out the source's left edge → down/up the lane → into the target's left edge
|
|
676
|
+
pts = [(box_x, sy), (lane_x, sy), (lane_x, ey), (box_x, ey)]
|
|
677
|
+
edges.append({"src": s["id"], "dst": d["id"], "label": r["on"], "points": pts,
|
|
678
|
+
"lane_x": lane_x, "mid_y": (sy + ey) / 2})
|
|
679
|
+
|
|
680
|
+
return {"nodes": nodes, "edges": edges, "diagram_type": "erDiagram",
|
|
681
|
+
"width": round(box_x + _ER_W + _MARGIN), "height": round(total_h)}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ── Excalidraw export (container-bound text, bound arrows, theme=light) ─────────
|
|
685
|
+
|
|
686
|
+
def to_excalidraw(graph: dict[str, Any]) -> dict[str, Any]:
|
|
687
|
+
"""Render the layout as Excalidraw scene JSON. Every box carries a container-BOUND text element
|
|
688
|
+
(so the label auto-wraps and stays vertically centred INSIDE the box, forever), and every arrow
|
|
689
|
+
is bound to real start/end elements. Ids/seeds are deterministic — same SQL, byte-identical JSON."""
|
|
690
|
+
if graph.get("diagram_type") == "erDiagram":
|
|
691
|
+
return _excalidraw_er(graph)
|
|
692
|
+
lay = _layout(graph)
|
|
693
|
+
elements: list[dict[str, Any]] = []
|
|
694
|
+
rect_by_id: dict[str, dict[str, Any]] = {}
|
|
695
|
+
|
|
696
|
+
def _base(i: int) -> dict[str, Any]:
|
|
697
|
+
return {"angle": 0, "strokeWidth": 1.5, "strokeStyle": "solid", "fillStyle": "solid",
|
|
698
|
+
"roughness": 1, "opacity": 100, "groupIds": [], "frameId": None,
|
|
699
|
+
"roundness": {"type": 3}, "seed": 1000 + i, "version": 1, "versionNonce": 5000 + i,
|
|
700
|
+
"isDeleted": False, "boundElements": [], "updated": 1, "link": None, "locked": False}
|
|
701
|
+
|
|
702
|
+
for i, n in enumerate(lay["nodes"]):
|
|
703
|
+
fill, stroke = _EXCALI[n["role"]]
|
|
704
|
+
text_id = f"{n['id']}__label"
|
|
705
|
+
rect = {**_base(i), "id": n["id"], "type": "rectangle",
|
|
706
|
+
"x": round(n["x"], 2), "y": round(n["y"], 2),
|
|
707
|
+
"width": n["w"], "height": n["h"],
|
|
708
|
+
"strokeColor": stroke, "backgroundColor": fill,
|
|
709
|
+
"boundElements": [{"type": "text", "id": text_id}],
|
|
710
|
+
"customData": {"kind": n["role"]}}
|
|
711
|
+
text_h = len(n["lines"]) * n["line_px"]
|
|
712
|
+
text = {**_base(1000 + i), "id": text_id, "type": "text",
|
|
713
|
+
"x": round(n["x"] + _PAD_H, 2),
|
|
714
|
+
"y": round(n["y"] + (n["h"] - text_h) / 2, 2),
|
|
715
|
+
"width": n["w"] - 2 * _PAD_H, "height": text_h,
|
|
716
|
+
"strokeColor": _EXCALI_INK, "backgroundColor": "transparent",
|
|
717
|
+
"text": n["text"], "originalText": n["text"], "fontSize": n["font"],
|
|
718
|
+
"fontFamily": 2, "textAlign": n["align"],
|
|
719
|
+
"verticalAlign": "middle" if n["align"] == "center" else "top",
|
|
720
|
+
"containerId": n["id"], "lineHeight": 1.25, "autoResize": False,
|
|
721
|
+
"roundness": None}
|
|
722
|
+
elements.append(rect)
|
|
723
|
+
elements.append(text)
|
|
724
|
+
rect_by_id[n["id"]] = rect
|
|
725
|
+
|
|
726
|
+
for j, e in enumerate(lay["edges"]):
|
|
727
|
+
src, dst = rect_by_id[e["src"]], rect_by_id[e["dst"]]
|
|
728
|
+
slot, slots = e.get("slot", 0), e.get("slots", 1)
|
|
729
|
+
sx, sy = src["x"] + src["width"], src["y"] + src["height"] / 2
|
|
730
|
+
ex = dst["x"]
|
|
731
|
+
# fan-in: spread arrowheads along the target's left edge (and tell Excalidraw the same
|
|
732
|
+
# via binding `focus`) so N arrows land on N distinct points, never one pile-up.
|
|
733
|
+
if slots <= 1:
|
|
734
|
+
ey, focus = dst["y"] + dst["height"] / 2, 0
|
|
735
|
+
else:
|
|
736
|
+
span = min(dst["height"] - 20, 26.0 * (slots - 1))
|
|
737
|
+
ey = dst["y"] + dst["height"] / 2 - span / 2 + slot * (span / (slots - 1))
|
|
738
|
+
focus = round((slot / (slots - 1)) * 1.2 - 0.6, 3)
|
|
739
|
+
arrow_id = f"edge_{j}"
|
|
740
|
+
arrow = {**_base(3000 + j), "id": arrow_id, "type": "arrow",
|
|
741
|
+
"x": round(sx, 2), "y": round(sy, 2),
|
|
742
|
+
"width": round(abs(ex - sx), 2), "height": round(abs(ey - sy), 2),
|
|
743
|
+
"strokeColor": _EDGE_COLOR, "backgroundColor": "transparent",
|
|
744
|
+
"points": [[0, 0], [round(ex - sx, 2), round(ey - sy, 2)]],
|
|
745
|
+
"lastCommittedPoint": None, "startArrowhead": None, "endArrowhead": "arrow",
|
|
746
|
+
"roundness": {"type": 2},
|
|
747
|
+
"startBinding": {"elementId": e["src"], "focus": 0, "gap": 6},
|
|
748
|
+
"endBinding": {"elementId": e["dst"], "focus": focus, "gap": 6}}
|
|
749
|
+
elements.append(arrow)
|
|
750
|
+
src["boundElements"].append({"type": "arrow", "id": arrow_id})
|
|
751
|
+
dst["boundElements"].append({"type": "arrow", "id": arrow_id})
|
|
752
|
+
if e.get("label"):
|
|
753
|
+
# join label floats BESIDE its arrow, never on it: staggered above (early slots) or
|
|
754
|
+
# below (late slots) the line, so every arrow stays fully visible. Unbound on purpose —
|
|
755
|
+
# a bound label sits centred ON the line and knocks a hole in it.
|
|
756
|
+
label_lines, lw, lh = _edge_label_size(e["label"])
|
|
757
|
+
mx, my = sx + (ex - sx) / 2, sy + (ey - sy) / 2
|
|
758
|
+
above = slots <= 1 or slot < slots / 2
|
|
759
|
+
# a slanted line rises/falls across the label's width — clear that too, not just
|
|
760
|
+
# the midpoint, so the label can never touch its own arrow
|
|
761
|
+
rise = abs((ey - sy) / (ex - sx)) * lw / 2 if ex != sx else 0.0
|
|
762
|
+
ly = (my - rise - 8 - lh) if above else (my + rise + 8)
|
|
763
|
+
label = {**_base(6000 + j), "id": f"{arrow_id}__label", "type": "text",
|
|
764
|
+
"x": round(mx - lw / 2, 2), "y": round(ly, 2),
|
|
765
|
+
"width": lw, "height": lh,
|
|
766
|
+
"strokeColor": _EDGE_COLOR, "backgroundColor": "transparent",
|
|
767
|
+
"text": "\n".join(label_lines), "originalText": "\n".join(label_lines),
|
|
768
|
+
"fontSize": _EDGE_LABEL_FONT, "fontFamily": 2, "textAlign": "center",
|
|
769
|
+
"verticalAlign": "top", "containerId": None,
|
|
770
|
+
"lineHeight": 1.25, "autoResize": False, "roundness": None,
|
|
771
|
+
"customData": {"edgeLabelOf": arrow_id}}
|
|
772
|
+
elements.append(label)
|
|
773
|
+
|
|
774
|
+
return {"type": "excalidraw", "version": 2, "source": "db-diagram skill",
|
|
775
|
+
"elements": elements, "files": {},
|
|
776
|
+
"appState": {"viewBackgroundColor": "#ffffff", "gridSize": None, "theme": "light"}}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _excalidraw_er(graph: dict[str, Any]) -> dict[str, Any]:
|
|
780
|
+
"""ER scene: each entity is a rectangle + a distinct HEADER text (bold-ish via larger font in
|
|
781
|
+
the role colour) + a divider line + a BULLET-LIST columns text (a container-bound text can't
|
|
782
|
+
carry two styles, so header and columns are separate elements). FK arrows are multi-point
|
|
783
|
+
orthogonal elbows routed through the left channel — provably never over a non-endpoint box."""
|
|
784
|
+
lay = _layout_er(graph)
|
|
785
|
+
fill, stroke = _EXCALI["entity"]
|
|
786
|
+
elements: list[dict[str, Any]] = []
|
|
787
|
+
rect_by_id: dict[str, dict[str, Any]] = {}
|
|
788
|
+
|
|
789
|
+
def _base(i: int) -> dict[str, Any]:
|
|
790
|
+
return {"angle": 0, "strokeWidth": 1.5, "strokeStyle": "solid", "fillStyle": "solid",
|
|
791
|
+
"roughness": 0, "opacity": 100, "groupIds": [], "frameId": None,
|
|
792
|
+
"roundness": {"type": 3}, "seed": 1000 + i, "version": 1, "versionNonce": 5000 + i,
|
|
793
|
+
"isDeleted": False, "boundElements": [], "updated": 1, "link": None, "locked": False}
|
|
794
|
+
|
|
795
|
+
def _text(i: int, x: float, y: float, w: float, h: float, s: str, size: int,
|
|
796
|
+
color: str, part: str, of: str) -> dict[str, Any]:
|
|
797
|
+
return {**_base(i), "id": f"{of}__{part}", "type": "text", "x": round(x, 2), "y": round(y, 2),
|
|
798
|
+
"width": round(w, 2), "height": round(h, 2), "strokeColor": color,
|
|
799
|
+
"backgroundColor": "transparent", "text": s, "originalText": s, "fontSize": size,
|
|
800
|
+
"fontFamily": 2, "textAlign": "left", "verticalAlign": "top", "containerId": None,
|
|
801
|
+
"lineHeight": 1.25, "autoResize": False, "roundness": None,
|
|
802
|
+
"customData": {"entityPart": part, "of": of}}
|
|
803
|
+
|
|
804
|
+
i = 0
|
|
805
|
+
for n in lay["nodes"]:
|
|
806
|
+
rect = {**_base(i), "id": n["id"], "type": "rectangle",
|
|
807
|
+
"x": round(n["x"], 2), "y": round(n["y"], 2), "width": n["w"], "height": n["h"],
|
|
808
|
+
"strokeColor": stroke, "backgroundColor": fill, "boundElements": [],
|
|
809
|
+
"customData": {"kind": "entity"}}
|
|
810
|
+
elements.append(rect)
|
|
811
|
+
rect_by_id[n["id"]] = rect
|
|
812
|
+
i += 1
|
|
813
|
+
tx = n["x"] + _ER_PAD_H
|
|
814
|
+
inner_w = n["w"] - 2 * _ER_PAD_H
|
|
815
|
+
hdr_y = n["y"] + _ER_PAD_V
|
|
816
|
+
hdr_txt = "\n".join(n["header_lines"])
|
|
817
|
+
elements.append(_text(1000 + i, tx, hdr_y, inner_w, len(n["header_lines"]) * _ER_HDR_LINE,
|
|
818
|
+
hdr_txt, _ER_HDR_FONT, stroke, "header", n["id"])); i += 1
|
|
819
|
+
div_y = hdr_y + len(n["header_lines"]) * _ER_HDR_LINE + _ER_DIV_GAP / 2
|
|
820
|
+
elements.append({**_base(1000 + i), "id": f"{n['id']}__div", "type": "line",
|
|
821
|
+
"x": round(tx, 2), "y": round(div_y, 2), "width": round(inner_w, 2),
|
|
822
|
+
"height": 0, "strokeColor": _ER_DIV_LIGHT, "strokeWidth": 1,
|
|
823
|
+
"points": [[0, 0], [round(inner_w, 2), 0]], "lastCommittedPoint": None,
|
|
824
|
+
"startBinding": None, "endBinding": None, "startArrowhead": None,
|
|
825
|
+
"endArrowhead": None, "roundness": None}); i += 1
|
|
826
|
+
col_y = hdr_y + len(n["header_lines"]) * _ER_HDR_LINE + _ER_DIV_GAP
|
|
827
|
+
col_txt = "\n".join(("• " if c["first"] else " ") + c["text"] for c in n["col_lines"])
|
|
828
|
+
elements.append(_text(1000 + i, tx, col_y, inner_w, len(n["col_lines"]) * _ER_COL_LINE,
|
|
829
|
+
col_txt, _ER_COL_FONT, _EXCALI_INK, "columns", n["id"])); i += 1
|
|
830
|
+
|
|
831
|
+
for j, e in enumerate(lay["edges"]):
|
|
832
|
+
pts = e["points"]
|
|
833
|
+
x0, y0 = pts[0]
|
|
834
|
+
xs = [p[0] for p in pts]
|
|
835
|
+
ys = [p[1] for p in pts]
|
|
836
|
+
arrow_id = f"edge_{j}"
|
|
837
|
+
arrow = {**_base(3000 + j), "id": arrow_id, "type": "arrow",
|
|
838
|
+
"x": round(x0, 2), "y": round(y0, 2),
|
|
839
|
+
"width": round(max(xs) - min(xs), 2), "height": round(max(ys) - min(ys), 2),
|
|
840
|
+
"strokeColor": _EDGE_COLOR, "backgroundColor": "transparent", "strokeWidth": 2,
|
|
841
|
+
"points": [[round(px - x0, 2), round(py - y0, 2)] for px, py in pts],
|
|
842
|
+
"lastCommittedPoint": None, "startArrowhead": None, "endArrowhead": "triangle",
|
|
843
|
+
"roundness": None,
|
|
844
|
+
"startBinding": {"elementId": e["src"], "focus": 0, "gap": 2},
|
|
845
|
+
"endBinding": {"elementId": e["dst"], "focus": 0, "gap": 2}}
|
|
846
|
+
elements.append(arrow)
|
|
847
|
+
rect_by_id[e["src"]]["boundElements"].append({"type": "arrow", "id": arrow_id})
|
|
848
|
+
rect_by_id[e["dst"]]["boundElements"].append({"type": "arrow", "id": arrow_id})
|
|
849
|
+
if e.get("label"):
|
|
850
|
+
lines, lw, lh = _edge_label_size(e["label"])
|
|
851
|
+
label = {**_base(6000 + j), "id": f"{arrow_id}__label", "type": "text",
|
|
852
|
+
"x": round(e["lane_x"] + 6, 2), "y": round(e["mid_y"] - lh / 2, 2),
|
|
853
|
+
"width": lw, "height": lh, "strokeColor": _EDGE_COLOR,
|
|
854
|
+
"backgroundColor": "transparent", "text": "\n".join(lines),
|
|
855
|
+
"originalText": "\n".join(lines), "fontSize": _EDGE_LABEL_FONT,
|
|
856
|
+
"fontFamily": 2, "textAlign": "left", "verticalAlign": "top",
|
|
857
|
+
"containerId": None, "lineHeight": 1.25, "autoResize": False,
|
|
858
|
+
"roundness": None, "customData": {"edgeLabelOf": arrow_id}}
|
|
859
|
+
elements.append(label)
|
|
860
|
+
|
|
861
|
+
return {"type": "excalidraw", "version": 2, "source": "db-diagram skill",
|
|
862
|
+
"elements": elements, "files": {},
|
|
863
|
+
"appState": {"viewBackgroundColor": "#ffffff", "gridSize": None, "theme": "light"}}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
# ── SVG / HTML export (self-contained, dual-theme via CSS custom props) ─────────
|
|
867
|
+
|
|
868
|
+
# role -> (fill, stroke, ink); page-level keys bg/ink/edge/edge_ink. Light is the default.
|
|
869
|
+
# "Jewel on ivory": harbor teal / plum / saffron / madder / ink-blue / viridian on warm ivory.
|
|
870
|
+
_SVG_ROLES = ["table", "cte", "filter", "aggregate", "result", "projection", "entity"]
|
|
871
|
+
_SVG_LIGHT = {
|
|
872
|
+
"page": {"bg": "#ffffff", "ink": "#1C2431", "edge": "#A39E93", "edge_ink": "#5B6472",
|
|
873
|
+
"div": "#D8DBE0"},
|
|
874
|
+
"table": ("#EDF7F6", "#0E7C7B", "#073938"), "cte": ("#F4EEF8", "#7B4B94", "#3A2149"),
|
|
875
|
+
"filter": ("#FBF1E3", "#C36F09", "#5E3604"), "aggregate": ("#F9ECEE", "#B23A48", "#571C24"),
|
|
876
|
+
"result": ("#EBF1FA", "#2D5DA1", "#16294D"), "projection": ("#EDF5F0", "#4A7C59", "#1F3A29"),
|
|
877
|
+
"entity": ("#EDF7F6", "#0E7C7B", "#073938"),
|
|
878
|
+
}
|
|
879
|
+
_SVG_DARK = {
|
|
880
|
+
"page": {"bg": "#10151F", "ink": "#E8EAF2", "edge": "#4E586C", "edge_ink": "#9FB0C8",
|
|
881
|
+
"div": "#2A3346"},
|
|
882
|
+
"table": ("#0D2B2A", "#2FB5AE", "#BDECE9"), "cte": ("#251A31", "#B08DD9", "#E3D4F4"),
|
|
883
|
+
"filter": ("#2E2110", "#E0A33E", "#F5DFB8"), "aggregate": ("#2E151A", "#E06D7E", "#F6C9D0"),
|
|
884
|
+
"result": ("#131F35", "#6E9BE8", "#C9DAF8"), "projection": ("#14261B", "#66B285", "#C5E8D2"),
|
|
885
|
+
"entity": ("#0D2B2A", "#2FB5AE", "#BDECE9"),
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _vars_block(selector: str, theme: dict[str, Any]) -> str:
|
|
890
|
+
p = theme["page"]
|
|
891
|
+
out = [selector + " {",
|
|
892
|
+
f" --dbd-bg:{p['bg']}; --dbd-ink:{p['ink']}; --dbd-edge:{p['edge']}; "
|
|
893
|
+
f"--dbd-edge-ink:{p['edge_ink']}; --dbd-div:{p.get('div', p['edge'])};"]
|
|
894
|
+
for role in _SVG_ROLES:
|
|
895
|
+
f, s, i = theme[role]
|
|
896
|
+
out.append(f" --dbd-{role}-fill:{f}; --dbd-{role}-stroke:{s}; --dbd-{role}-ink:{i};")
|
|
897
|
+
out.append("}")
|
|
898
|
+
return "\n".join(out)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _svg_struct_css() -> str:
|
|
902
|
+
"""Which element consumes which var — identical whether standalone or embedded in HTML."""
|
|
903
|
+
rules = [".dbd-node rect { stroke-width: 1.5; }",
|
|
904
|
+
".dbd-node text { font-family: 'Segoe UI', system-ui, sans-serif; }",
|
|
905
|
+
".dbd-edge { stroke: var(--dbd-edge); stroke-width: 1.5; fill: none; marker-end: url(#dbd-arrow); }",
|
|
906
|
+
".dbd-edge-label { fill: var(--dbd-edge-ink); font: 12px 'Segoe UI', system-ui, sans-serif; "
|
|
907
|
+
"paint-order: stroke; stroke: var(--dbd-bg); stroke-width: 4px; stroke-linejoin: round; }",
|
|
908
|
+
"#dbd-arrow path { fill: var(--dbd-edge); }"]
|
|
909
|
+
for role in _SVG_ROLES:
|
|
910
|
+
rules.append(f".dbd-node.{role} rect {{ fill: var(--dbd-{role}-fill); stroke: var(--dbd-{role}-stroke); }}")
|
|
911
|
+
rules.append(f".dbd-node.{role} text {{ fill: var(--dbd-{role}-ink); }}")
|
|
912
|
+
# ER entity box: a HEADER visually distinct from its BULLET columns (higher specificity than
|
|
913
|
+
# the generic `.dbd-node.entity text` rule above, so the header keeps the role's stroke colour).
|
|
914
|
+
rules.append(".dbd-node.entity text.dbd-entity-hdr { fill: var(--dbd-entity-stroke); font-weight: 700; }")
|
|
915
|
+
rules.append(".dbd-node.entity .dbd-entity-bullet { fill: var(--dbd-edge); }")
|
|
916
|
+
rules.append(".dbd-node.entity line.dbd-entity-div { stroke: var(--dbd-div); stroke-width: 1; }")
|
|
917
|
+
return "\n".join(rules)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _esc(s: str) -> str:
|
|
921
|
+
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _svg_node(n: dict[str, Any]) -> str:
|
|
925
|
+
x, y, w, h = n["x"], n["y"], n["w"], n["h"]
|
|
926
|
+
font = n["font"]
|
|
927
|
+
total = len(n["lines"]) * n["line_px"]
|
|
928
|
+
if n["align"] == "center":
|
|
929
|
+
anchor, tx = "middle", x + w / 2
|
|
930
|
+
first = y + (h - total) / 2 + font * 0.8
|
|
931
|
+
else:
|
|
932
|
+
anchor, tx = "start", x + _PAD_H
|
|
933
|
+
first = y + _PAD_V + font * 0.8
|
|
934
|
+
tspans = "".join(
|
|
935
|
+
f'<tspan x="{round(tx, 1)}" y="{round(first + i * n["line_px"], 1)}"'
|
|
936
|
+
+ (' font-weight="600"' if i == 0 else "") # title line reads as a header
|
|
937
|
+
+ f'>{_esc(line)}</tspan>'
|
|
938
|
+
for i, line in enumerate(n["lines"])
|
|
939
|
+
)
|
|
940
|
+
return (
|
|
941
|
+
f'<g class="dbd-node {n["role"]}">'
|
|
942
|
+
f'<rect x="{round(x, 1)}" y="{round(y, 1)}" width="{w}" height="{h}" rx="8"/>'
|
|
943
|
+
f'<text text-anchor="{anchor}" font-size="{font}">{tspans}</text>'
|
|
944
|
+
f'</g>'
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _svg_edges(lay: dict[str, Any]) -> tuple[str, str]:
|
|
949
|
+
"""Returns (paths, labels) separately: paths are painted UNDER the nodes, labels ON TOP of
|
|
950
|
+
everything (with a bg-colour halo) — an edge label can never end up hidden behind a box."""
|
|
951
|
+
by_id = {n["id"]: n for n in lay["nodes"]}
|
|
952
|
+
paths: list[str] = []
|
|
953
|
+
labels: list[str] = []
|
|
954
|
+
for e in lay["edges"]:
|
|
955
|
+
s, d = by_id[e["src"]], by_id[e["dst"]]
|
|
956
|
+
slot, slots = e.get("slot", 0), e.get("slots", 1)
|
|
957
|
+
sx, sy = s["x"] + s["w"], s["y"] + s["h"] / 2
|
|
958
|
+
ex = d["x"]
|
|
959
|
+
if slots <= 1:
|
|
960
|
+
ey = d["y"] + d["h"] / 2
|
|
961
|
+
else: # fan-in: N arrows land on N distinct points along the target's left edge
|
|
962
|
+
span = min(d["h"] - 20, 26.0 * (slots - 1))
|
|
963
|
+
ey = d["y"] + d["h"] / 2 - span / 2 + slot * (span / (slots - 1))
|
|
964
|
+
mx = (sx + ex) / 2
|
|
965
|
+
# gentle S-curve so parallel edges stay individually traceable
|
|
966
|
+
path = (f'M {round(sx, 1)},{round(sy, 1)} '
|
|
967
|
+
f'C {round(mx, 1)},{round(sy, 1)} {round(mx, 1)},{round(ey, 1)} '
|
|
968
|
+
f'{round(ex - 4, 1)},{round(ey, 1)}')
|
|
969
|
+
paths.append(f'<path class="dbd-edge" d="{path}"/>')
|
|
970
|
+
if e.get("label"):
|
|
971
|
+
# label floats ABOVE (early slots) or BELOW (late slots) the arrow — never on the
|
|
972
|
+
# line itself, so the arrow stays fully visible under all of its text.
|
|
973
|
+
elines, lw, lh = _edge_label_size(e["label"])
|
|
974
|
+
my = (sy + ey) / 2
|
|
975
|
+
above = slots <= 1 or slot < slots / 2
|
|
976
|
+
# clear the line's rise across the label width too (slanted fan-in arrows)
|
|
977
|
+
rise = abs((ey - sy) / (ex - sx)) * lw / 2 if ex != sx else 0.0
|
|
978
|
+
first_baseline = (my - rise - 8 - lh + _EDGE_LABEL_FONT) if above \
|
|
979
|
+
else (my + rise + 8 + _EDGE_LABEL_FONT)
|
|
980
|
+
for i, ln in enumerate(elines):
|
|
981
|
+
labels.append(f'<text class="dbd-edge-label" text-anchor="middle" '
|
|
982
|
+
f'x="{round(mx, 1)}" '
|
|
983
|
+
f'y="{round(first_baseline + i * (_EDGE_LABEL_FONT + 3), 1)}">{_esc(ln)}</text>')
|
|
984
|
+
return "".join(paths), "".join(labels)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _svg_er_node(n: dict[str, Any]) -> str:
|
|
988
|
+
"""An entity box: header (bold, larger, role-coloured) + divider rule + bulleted columns."""
|
|
989
|
+
x, y, w, h = n["x"], n["y"], n["w"], n["h"]
|
|
990
|
+
tx = x + _ER_PAD_H
|
|
991
|
+
parts = [f'<g class="dbd-node entity">',
|
|
992
|
+
f'<rect x="{round(x, 1)}" y="{round(y, 1)}" width="{w}" height="{h}" rx="10"/>']
|
|
993
|
+
hy = y + _ER_PAD_V + _ER_HDR_FONT * 0.85
|
|
994
|
+
for i, line in enumerate(n["header_lines"]):
|
|
995
|
+
parts.append(f'<text class="dbd-entity-hdr" x="{round(tx, 1)}" '
|
|
996
|
+
f'y="{round(hy + i * _ER_HDR_LINE, 1)}" font-size="{_ER_HDR_FONT}">{_esc(line)}</text>')
|
|
997
|
+
dy = y + _ER_PAD_V + len(n["header_lines"]) * _ER_HDR_LINE + _ER_DIV_GAP / 2
|
|
998
|
+
parts.append(f'<line class="dbd-entity-div" x1="{round(tx, 1)}" y1="{round(dy, 1)}" '
|
|
999
|
+
f'x2="{round(x + w - _ER_PAD_H, 1)}" y2="{round(dy, 1)}"/>')
|
|
1000
|
+
cy = y + _ER_PAD_V + len(n["header_lines"]) * _ER_HDR_LINE + _ER_DIV_GAP + _ER_COL_FONT * 0.85
|
|
1001
|
+
for i, c in enumerate(n["col_lines"]):
|
|
1002
|
+
yy = round(cy + i * _ER_COL_LINE, 1)
|
|
1003
|
+
if c["first"]:
|
|
1004
|
+
parts.append(f'<text class="dbd-entity-col" x="{round(tx, 1)}" y="{yy}" '
|
|
1005
|
+
f'font-size="{_ER_COL_FONT}"><tspan class="dbd-entity-bullet">•</tspan> '
|
|
1006
|
+
f'{_esc(c["text"])}</text>')
|
|
1007
|
+
else:
|
|
1008
|
+
parts.append(f'<text class="dbd-entity-col" x="{round(tx + _ER_BULLET_INDENT, 1)}" '
|
|
1009
|
+
f'y="{yy}" font-size="{_ER_COL_FONT}">{_esc(c["text"])}</text>')
|
|
1010
|
+
parts.append('</g>')
|
|
1011
|
+
return "".join(parts)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _svg_er_edges(lay: dict[str, Any]) -> tuple[str, str]:
|
|
1015
|
+
"""Orthogonal FK arrows (painted ON TOP of the boxes — never occluded) + FK labels beside the
|
|
1016
|
+
lane. Returns (paths, labels) so the caller can layer labels last."""
|
|
1017
|
+
paths: list[str] = []
|
|
1018
|
+
labels: list[str] = []
|
|
1019
|
+
for e in lay["edges"]:
|
|
1020
|
+
d = "M " + " L ".join(f"{round(px, 1)},{round(py, 1)}" for px, py in e["points"])
|
|
1021
|
+
paths.append(f'<path class="dbd-edge" d="{d}"/>')
|
|
1022
|
+
if e.get("label"):
|
|
1023
|
+
paths_x = e["lane_x"] + 6
|
|
1024
|
+
labels.append(f'<text class="dbd-edge-label" text-anchor="start" '
|
|
1025
|
+
f'x="{round(paths_x, 1)}" y="{round(e["mid_y"], 1)}">{_esc(e["label"])}</text>')
|
|
1026
|
+
return "".join(paths), "".join(labels)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def to_svg(graph: dict[str, Any], standalone: bool = True) -> str:
|
|
1030
|
+
"""Self-contained SVG. ``standalone`` embeds the theme variables (default LIGHT, follows
|
|
1031
|
+
prefers-color-scheme for dark); embedded (False) relies on the host page's variables so the
|
|
1032
|
+
HTML toggle can drive it. No external references — safe to inline anywhere."""
|
|
1033
|
+
is_er = graph.get("diagram_type") == "erDiagram"
|
|
1034
|
+
lay = _layout(graph)
|
|
1035
|
+
w, h = lay["width"], lay["height"]
|
|
1036
|
+
if standalone:
|
|
1037
|
+
dark_body = _vars_block(":root", _SVG_DARK).split("{", 1)[1].rsplit("}", 1)[0]
|
|
1038
|
+
style = (_vars_block(":root", _SVG_LIGHT) + "\n"
|
|
1039
|
+
+ "@media (prefers-color-scheme: dark){ :root {" + dark_body + "} }\n"
|
|
1040
|
+
+ _svg_struct_css())
|
|
1041
|
+
else:
|
|
1042
|
+
style = _svg_struct_css()
|
|
1043
|
+
defs = ('<defs><marker id="dbd-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
|
|
1044
|
+
'markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z"/></marker></defs>')
|
|
1045
|
+
bg = '<rect x="0" y="0" width="100%" height="100%" fill="var(--dbd-bg)"/>' if standalone else ""
|
|
1046
|
+
if is_er:
|
|
1047
|
+
# ER: boxes first, then orthogonal arrows ON TOP (routing guarantees no box crossings,
|
|
1048
|
+
# so top-painting just makes every FK unmistakably visible), then labels.
|
|
1049
|
+
edge_paths, edge_labels = _svg_er_edges(lay)
|
|
1050
|
+
body = "".join(_svg_er_node(n) for n in lay["nodes"]) + edge_paths + edge_labels
|
|
1051
|
+
else:
|
|
1052
|
+
edge_paths, edge_labels = _svg_edges(lay)
|
|
1053
|
+
# paint order: arrows under boxes, boxes, then edge labels ON TOP (halo keeps them readable)
|
|
1054
|
+
body = edge_paths + "".join(_svg_node(n) for n in lay["nodes"]) + edge_labels
|
|
1055
|
+
return (
|
|
1056
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" '
|
|
1057
|
+
f'role="img" aria-label="database diagram">'
|
|
1058
|
+
f'<style>{style}</style>{defs}{bg}{body}</svg>'
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def to_html(graph: dict[str, Any], *, title: str = "DB diagram",
|
|
1063
|
+
source: str = "", date: str = "") -> str:
|
|
1064
|
+
"""Self-contained HTML page: the SVG inline + a light/dark toggle. Default is LIGHT (it does NOT
|
|
1065
|
+
auto-follow the OS to dark — dark is opt-in via the toggle), and there are zero external requests."""
|
|
1066
|
+
svg = to_svg(graph, standalone=False)
|
|
1067
|
+
# Fully attribute-driven: BOTH light and dark are gated on data-theme, and NO @media rule
|
|
1068
|
+
# competes with the toggle (that fight is what made the page half-apply / go black). An inline
|
|
1069
|
+
# head script picks the initial theme from the OS once; the button just flips the attribute.
|
|
1070
|
+
light = _vars_block(':root[data-theme="light"]', _SVG_LIGHT)
|
|
1071
|
+
dark = _vars_block(':root[data-theme="dark"]', _SVG_DARK)
|
|
1072
|
+
struct = _svg_struct_css()
|
|
1073
|
+
meta = " · ".join(x for x in [_esc(source), _esc(date)] if x)
|
|
1074
|
+
caveat = ("This diagram is the query's STRUCTURE, extracted from the SQL text — not its execution "
|
|
1075
|
+
"plan. The optimizer decides actual join order, index use, and physical operators at "
|
|
1076
|
+
"runtime; verify performance against the real plan.")
|
|
1077
|
+
return f"""<!doctype html>
|
|
1078
|
+
<html lang="en" data-theme="light">
|
|
1079
|
+
<head>
|
|
1080
|
+
<meta charset="utf-8">
|
|
1081
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1082
|
+
<title>{_esc(title)}</title>
|
|
1083
|
+
<script>document.documentElement.dataset.theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';</script>
|
|
1084
|
+
<style>
|
|
1085
|
+
{light}
|
|
1086
|
+
{dark}
|
|
1087
|
+
{struct}
|
|
1088
|
+
* {{ box-sizing: border-box; }}
|
|
1089
|
+
body {{ margin: 0; padding: 24px; background: var(--dbd-bg); color: var(--dbd-ink);
|
|
1090
|
+
font-family: 'Segoe UI', system-ui, sans-serif; transition: background .2s, color .2s; }}
|
|
1091
|
+
header {{ display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }}
|
|
1092
|
+
h1 {{ font-size: 18px; margin: 0; }}
|
|
1093
|
+
.meta {{ opacity: .65; font-size: 13px; }}
|
|
1094
|
+
.toggle {{ margin-left: auto; cursor: pointer; border: 1px solid var(--dbd-edge);
|
|
1095
|
+
background: transparent; color: inherit; border-radius: 6px; padding: 6px 12px; font: inherit; }}
|
|
1096
|
+
.diagram {{ overflow-x: auto; border: 1px solid var(--dbd-edge); border-radius: 10px; padding: 8px; }}
|
|
1097
|
+
.diagram svg {{ max-width: 100%; height: auto; display: block; }}
|
|
1098
|
+
.caveat {{ margin-top: 16px; font-size: 12px; opacity: .7; max-width: 70ch; line-height: 1.5; }}
|
|
1099
|
+
</style>
|
|
1100
|
+
</head>
|
|
1101
|
+
<body>
|
|
1102
|
+
<header>
|
|
1103
|
+
<h1>{_esc(title)}</h1>
|
|
1104
|
+
<span class="meta">{meta}</span>
|
|
1105
|
+
<button class="toggle" onclick="var r=document.documentElement;r.dataset.theme=r.dataset.theme==='dark'?'light':'dark';this.textContent=r.dataset.theme==='dark'?'☀ Light':'☾ Dark';">☾ Dark</button>
|
|
1106
|
+
</header>
|
|
1107
|
+
<div class="diagram">{svg}</div>
|
|
1108
|
+
<p class="caveat">{caveat}</p>
|
|
1109
|
+
</body>
|
|
1110
|
+
</html>"""
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
# ── public API ────────────────────────────────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
def analyze(sql: str, dialect: str = "tsql") -> dict[str, Any]:
|
|
1116
|
+
"""Parse ``sql`` into the typed-graph model + a Mermaid skeleton. Raises
|
|
1117
|
+
``SqlglotUnavailable`` if sqlglot isn't installed; returns a ``confidence: partial``
|
|
1118
|
+
result (never raises) when the SQL itself can't be parsed."""
|
|
1119
|
+
if not _HAVE_SQLGLOT:
|
|
1120
|
+
raise SqlglotUnavailable(
|
|
1121
|
+
"sqlglot is not installed — the deterministic SQL parse can't run. Either "
|
|
1122
|
+
"`pip install sqlglot`, or (per the db-diagram skill) hand-parse the SQL text and "
|
|
1123
|
+
"mark every inferred element clearly."
|
|
1124
|
+
)
|
|
1125
|
+
try:
|
|
1126
|
+
statements = [s for s in sqlglot.parse(sql, dialect=dialect) if s is not None]
|
|
1127
|
+
except Exception as exc: # noqa: BLE001 — a parse failure degrades, never crashes the skill
|
|
1128
|
+
return {
|
|
1129
|
+
"diagram_type": "flowchart",
|
|
1130
|
+
"confidence": "partial",
|
|
1131
|
+
"inferred": [f"sqlglot could not parse this SQL ({type(exc).__name__}); "
|
|
1132
|
+
"the structure below is best-effort and must be verified by hand"],
|
|
1133
|
+
"source_tables": [], "ctes": [], "joins": [], "filters": [],
|
|
1134
|
+
"projection": None, "aggregate": None, "set_op": None,
|
|
1135
|
+
"entities": [], "relationships": [],
|
|
1136
|
+
"mermaid": "flowchart TD\n unparsed[\"(could not parse SQL — hand-verify)\"]",
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if not statements:
|
|
1140
|
+
return {
|
|
1141
|
+
"diagram_type": "flowchart", "confidence": "partial",
|
|
1142
|
+
"inferred": ["empty or comment-only SQL — nothing to diagram"],
|
|
1143
|
+
"source_tables": [], "ctes": [], "joins": [], "filters": [],
|
|
1144
|
+
"projection": None, "aggregate": None, "set_op": None,
|
|
1145
|
+
"entities": [], "relationships": [],
|
|
1146
|
+
"mermaid": "flowchart TD\n empty[\"(no SQL statements found)\"]",
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
creates = [s for s in statements if isinstance(s, exp.Create)]
|
|
1150
|
+
if creates and len(creates) == len(statements):
|
|
1151
|
+
return _analyze_ddl(statements, dialect)
|
|
1152
|
+
|
|
1153
|
+
# query / proc: analyze the last top-level SELECT-bearing statement.
|
|
1154
|
+
query = next((s for s in reversed(statements) if s.find(exp.Select)), statements[-1])
|
|
1155
|
+
return _analyze_query(query, dialect)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def render(graph: dict[str, Any], fmt: str, **kw: Any) -> str:
|
|
1159
|
+
"""Render a parsed graph to a chosen format: mermaid | excalidraw | svg | html | json."""
|
|
1160
|
+
if fmt == "mermaid":
|
|
1161
|
+
return graph["mermaid"]
|
|
1162
|
+
if fmt == "excalidraw":
|
|
1163
|
+
return json.dumps(to_excalidraw(graph), indent=2)
|
|
1164
|
+
if fmt == "svg":
|
|
1165
|
+
return to_svg(graph)
|
|
1166
|
+
if fmt == "html":
|
|
1167
|
+
return to_html(graph, **kw)
|
|
1168
|
+
if fmt == "json":
|
|
1169
|
+
return json.dumps(graph, indent=2)
|
|
1170
|
+
raise ValueError(f"unknown format: {fmt!r}")
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1174
|
+
ap = argparse.ArgumentParser(description="Deterministic SQL -> diagram extractor (db-diagram skill).")
|
|
1175
|
+
src = ap.add_mutually_exclusive_group()
|
|
1176
|
+
src.add_argument("--file", help="path to a .sql file")
|
|
1177
|
+
src.add_argument("--sql", help="inline SQL string")
|
|
1178
|
+
ap.add_argument("--dialect", default="tsql", help="sqlglot dialect (default: tsql)")
|
|
1179
|
+
ap.add_argument("--format", default="mermaid",
|
|
1180
|
+
choices=["mermaid", "excalidraw", "svg", "html", "json"],
|
|
1181
|
+
help="output format (default: mermaid)")
|
|
1182
|
+
ap.add_argument("--json", action="store_true",
|
|
1183
|
+
help="shorthand for --format json (full graph JSON)")
|
|
1184
|
+
ap.add_argument("--title", default="DB diagram", help="title for --format html")
|
|
1185
|
+
args = ap.parse_args(argv)
|
|
1186
|
+
|
|
1187
|
+
# SVG/HTML output can carry non-ASCII (theme toggle glyphs, the · separator); force UTF-8 so a
|
|
1188
|
+
# Windows cp1252 console doesn't choke when the output is redirected to a file.
|
|
1189
|
+
try:
|
|
1190
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
1191
|
+
except Exception: # noqa: BLE001 — older/odd streams: fall back to default encoding
|
|
1192
|
+
pass
|
|
1193
|
+
|
|
1194
|
+
if args.file:
|
|
1195
|
+
sql = open(args.file, encoding="utf-8").read()
|
|
1196
|
+
elif args.sql:
|
|
1197
|
+
sql = args.sql
|
|
1198
|
+
else:
|
|
1199
|
+
sql = sys.stdin.read()
|
|
1200
|
+
|
|
1201
|
+
try:
|
|
1202
|
+
g = analyze(sql, dialect=args.dialect)
|
|
1203
|
+
except SqlglotUnavailable as exc:
|
|
1204
|
+
sys.stderr.write(f"{exc}\n")
|
|
1205
|
+
return 3
|
|
1206
|
+
fmt = "json" if args.json else args.format
|
|
1207
|
+
print(render(g, fmt, title=args.title, source=(args.file or "")))
|
|
1208
|
+
return 0
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
if __name__ == "__main__":
|
|
1212
|
+
raise SystemExit(main())
|