@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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -1
  3. package/bundles/antigravity-plugin/agents/code-reviewer.md +47 -0
  4. package/bundles/antigravity-plugin/agents/preflight.md +53 -0
  5. package/bundles/antigravity-plugin/guard_agy.py +338 -0
  6. package/bundles/antigravity-plugin/hooks.json +36 -0
  7. package/bundles/antigravity-plugin/mcp_config.json +8 -0
  8. package/bundles/antigravity-plugin/mcp_ping_server.py +55 -0
  9. package/bundles/antigravity-plugin/plugin.json +5 -0
  10. package/bundles/antigravity-plugin/session_end_agy.py +57 -0
  11. package/bundles/antigravity-plugin/skills/_registry.md +115 -0
  12. package/bundles/antigravity-plugin/skills/add-rules/SKILL.md +45 -0
  13. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +249 -0
  14. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +305 -0
  15. package/bundles/antigravity-plugin/skills/best-practices/SKILL.md +500 -0
  16. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +263 -0
  17. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +326 -0
  18. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +245 -0
  19. package/bundles/antigravity-plugin/skills/best-practices/references/anti-patterns.md +571 -0
  20. package/bundles/antigravity-plugin/skills/best-practices/references/before-after-examples.md +1114 -0
  21. package/bundles/antigravity-plugin/skills/best-practices/references/best-practices-guide.md +513 -0
  22. package/bundles/antigravity-plugin/skills/best-practices/references/common-workflows.md +692 -0
  23. package/bundles/antigravity-plugin/skills/best-practices/references/prompt-patterns.md +547 -0
  24. package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +57 -0
  25. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +315 -0
  26. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +271 -0
  27. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +122 -0
  28. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +204 -0
  29. package/bundles/antigravity-plugin/skills/context-curator/SKILL.md +157 -0
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +40 -0
  31. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +305 -0
  32. package/bundles/antigravity-plugin/skills/css-architecture/references/RESPONSIVE-DESIGN.md +604 -0
  33. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +177 -0
  34. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +148 -0
  35. package/bundles/antigravity-plugin/skills/db-diagram/scripts/sql_to_graph.py +1212 -0
  36. package/bundles/antigravity-plugin/skills/digress/SKILL.md +61 -0
  37. package/bundles/antigravity-plugin/skills/draw-io/SKILL.md +162 -0
  38. package/bundles/antigravity-plugin/skills/draw-io/references/aws-icons.md +677 -0
  39. package/bundles/antigravity-plugin/skills/draw-io/references/layout-guidelines.md +142 -0
  40. package/bundles/antigravity-plugin/skills/draw-io/references/troubleshooting.md +118 -0
  41. package/bundles/antigravity-plugin/skills/draw-io/references/workflows.md +103 -0
  42. package/bundles/antigravity-plugin/skills/draw-io/scripts/convert-drawio-to-png.sh +25 -0
  43. package/bundles/antigravity-plugin/skills/draw-io/scripts/find_aws_icon.py +79 -0
  44. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +260 -0
  45. package/bundles/antigravity-plugin/skills/excalidraw-db/SKILL.md +38 -0
  46. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +300 -0
  47. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +198 -0
  48. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +193 -0
  49. package/bundles/antigravity-plugin/skills/frontend-design/references/my-tech-stack.md +127 -0
  50. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +195 -0
  51. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +285 -0
  52. package/bundles/antigravity-plugin/skills/golden-plan/SKILL.md +577 -0
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +100 -0
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +114 -0
  55. package/bundles/antigravity-plugin/skills/javascript-typescript/SKILL.md +142 -0
  56. package/bundles/antigravity-plugin/skills/kb/SKILL.md +60 -0
  57. package/bundles/antigravity-plugin/skills/mermaid-db/SKILL.md +33 -0
  58. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +236 -0
  59. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/ENHANCEMENTS.md +264 -0
  60. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/MERMAID-SUMMARY.md +137 -0
  61. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/advanced-features.md +556 -0
  62. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/architecture-diagrams.md +192 -0
  63. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/c4-diagrams.md +410 -0
  64. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/class-diagrams.md +361 -0
  65. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/erd-diagrams.md +510 -0
  66. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/flowcharts.md +450 -0
  67. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/sequence-diagrams.md +394 -0
  68. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/troubleshooting.md +335 -0
  69. package/bundles/antigravity-plugin/skills/mermaid-diagrams/references/workflows.md +418 -0
  70. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +68 -0
  71. package/bundles/antigravity-plugin/skills/mockup/SKILL.md +242 -0
  72. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +243 -0
  73. package/bundles/antigravity-plugin/skills/particle-art/references/canvas-utils.ts +171 -0
  74. package/bundles/antigravity-plugin/skills/particle-art/references/dot-field.template.tsx +203 -0
  75. package/bundles/antigravity-plugin/skills/particle-art/references/flow-field.template.tsx +263 -0
  76. package/bundles/antigravity-plugin/skills/particle-art/references/node-shape.template.tsx +261 -0
  77. package/bundles/antigravity-plugin/skills/particle-art/references/shape-sampler.ts +281 -0
  78. package/bundles/antigravity-plugin/skills/particle-art/references/stipple-morph.template.tsx +167 -0
  79. package/bundles/antigravity-plugin/skills/particle-art/references/stipple.template.tsx +175 -0
  80. package/bundles/antigravity-plugin/skills/particle-art/references/trail-ghost.template.tsx +266 -0
  81. package/bundles/antigravity-plugin/skills/particle-art/references/usage-examples.md +320 -0
  82. package/bundles/antigravity-plugin/skills/playwright/API_REFERENCE.md +653 -0
  83. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +454 -0
  84. package/bundles/antigravity-plugin/skills/playwright/lib/helpers.js +441 -0
  85. package/bundles/antigravity-plugin/skills/playwright/package.json +26 -0
  86. package/bundles/antigravity-plugin/skills/playwright/run.js +228 -0
  87. package/bundles/antigravity-plugin/skills/prd/SKILL.md +107 -0
  88. package/bundles/antigravity-plugin/skills/preflight/SKILL.md +435 -0
  89. package/bundles/antigravity-plugin/skills/python/SKILL.md +388 -0
  90. package/bundles/antigravity-plugin/skills/react-best-practices/SKILL.md +269 -0
  91. package/bundles/antigravity-plugin/skills/react-dev/README.md +404 -0
  92. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +459 -0
  93. package/bundles/antigravity-plugin/skills/react-dev/examples/generic-components.md +579 -0
  94. package/bundles/antigravity-plugin/skills/react-dev/examples/server-components.md +579 -0
  95. package/bundles/antigravity-plugin/skills/react-dev/references/event-handlers.md +574 -0
  96. package/bundles/antigravity-plugin/skills/react-dev/references/hooks.md +456 -0
  97. package/bundles/antigravity-plugin/skills/react-dev/references/react-19-patterns.md +638 -0
  98. package/bundles/antigravity-plugin/skills/react-dev/references/react-router.md +1002 -0
  99. package/bundles/antigravity-plugin/skills/react-dev/references/tanstack-router.md +587 -0
  100. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +486 -0
  101. package/bundles/antigravity-plugin/skills/resume/SKILL.md +36 -0
  102. package/bundles/antigravity-plugin/skills/security-review/SKILL.md +196 -0
  103. package/bundles/antigravity-plugin/skills/setup-project-ai/SKILL.md +61 -0
  104. package/bundles/antigravity-plugin/skills/ship/SKILL.md +107 -0
  105. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +103 -0
  106. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +102 -0
  107. package/bundles/antigravity-plugin/skills/skill-creator/LICENSE.txt +202 -0
  108. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +491 -0
  109. package/bundles/antigravity-plugin/skills/skill-creator/agents/analyzer.md +274 -0
  110. package/bundles/antigravity-plugin/skills/skill-creator/agents/comparator.md +202 -0
  111. package/bundles/antigravity-plugin/skills/skill-creator/agents/grader.md +223 -0
  112. package/bundles/antigravity-plugin/skills/skill-creator/assets/eval_review.html +146 -0
  113. package/bundles/antigravity-plugin/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  114. package/bundles/antigravity-plugin/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  115. package/bundles/antigravity-plugin/skills/skill-creator/references/schemas.md +430 -0
  116. package/bundles/antigravity-plugin/skills/skill-creator/scripts/__init__.py +0 -0
  117. package/bundles/antigravity-plugin/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  118. package/bundles/antigravity-plugin/skills/skill-creator/scripts/generate_report.py +326 -0
  119. package/bundles/antigravity-plugin/skills/skill-creator/scripts/improve_description.py +247 -0
  120. package/bundles/antigravity-plugin/skills/skill-creator/scripts/package_skill.py +136 -0
  121. package/bundles/antigravity-plugin/skills/skill-creator/scripts/quick_validate.py +103 -0
  122. package/bundles/antigravity-plugin/skills/skill-creator/scripts/run_eval.py +310 -0
  123. package/bundles/antigravity-plugin/skills/skill-creator/scripts/run_loop.py +328 -0
  124. package/bundles/antigravity-plugin/skills/skill-creator/scripts/utils.py +47 -0
  125. package/bundles/antigravity-plugin/skills/sql/SKILL.md +321 -0
  126. package/bundles/antigravity-plugin/skills/tdd/SKILL.md +37 -0
  127. package/bundles/antigravity-plugin/skills/tdd-workflow/SKILL.md +188 -0
  128. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +286 -0
  129. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +155 -0
  130. package/bundles/antigravity-plugin/skills/ui-brief/SKILL.md +84 -0
  131. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +176 -0
  132. package/bundles/antigravity-plugin/skills/ui-review/references/framework-fixes.md +471 -0
  133. package/bundles/antigravity-plugin/skills/ui-review/references/visual-checklist.md +236 -0
  134. package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +77 -0
  135. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +64 -0
  136. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +217 -0
  137. package/bundles/antigravity-plugin/skills/version/SKILL.md +18 -0
  138. package/bundles/antigravity-plugin/skills/warm-editorial-ui/DESIGN_TOKENS.md +487 -0
  139. package/bundles/antigravity-plugin/skills/warm-editorial-ui/IMPLEMENTATION_GUIDE.md +177 -0
  140. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +732 -0
  141. package/bundles/antigravity-plugin/skills/webapp-testing/LICENSE.txt +202 -0
  142. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +97 -0
  143. package/bundles/antigravity-plugin/skills/webapp-testing/examples/console_logging.py +35 -0
  144. package/bundles/antigravity-plugin/skills/webapp-testing/examples/element_discovery.py +40 -0
  145. package/bundles/antigravity-plugin/skills/webapp-testing/examples/static_html_automation.py +33 -0
  146. package/bundles/antigravity-plugin/skills/webapp-testing/scripts/with_server.py +106 -0
  147. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +880 -0
  148. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +384 -0
  149. package/bundles/antigravity-plugin/statusline-command-agy.sh +91 -0
  150. package/bundles/antigravity-plugin/warm_start_agy.py +149 -0
  151. package/bundles/manifest.json +6 -0
  152. package/dist/cli.js +5363 -0
  153. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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())