Skip to content

Commit 709d29b

Browse files
ystemsrxclaude
andcommitted
fix(parser): broaden DBML coverage, infer cardinality, harden error path
Three threads landed together from the same review pass: - DBML scanner: support schema-qualified table names (`Table auth.users`), multi-line `Ref name:\n a > b [...]` short statements, multi-line bracket-settings blocks, composite-column FKs, and skip TablePartial/DiagramView/records/Note/checks/`~partial` constructs that previously either dropped the table or produced phantom columns. - Cardinality: thread the `>` / `<` / `-` / `<>` operator through `ParsedRelationship`, expose `from/toCardinality`, and infer 1:1 when the FK column is a single-column PK or `unique` (covers DBML and SQL). Builder uses these instead of hardcoding `N` / `1`, so `user_profiles` with `[pk, ref: - users.id]` and `payments.order_id [unique]` now render as 1:1 instead of N:1. - Error UX: parse before persisting so a failed parse no longer writes a snapshot to IndexedDB; clear the prior graph and surface the error as an overlay inside the canvas instead of pushing the canvas down. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1022a87 commit 709d29b

9 files changed

Lines changed: 490 additions & 46 deletions

File tree

css/style.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,24 @@ body::before {
625625
gap: 10px;
626626
}
627627

628+
/* 解析失败时遮在画布上方的提示。loading-overlay 比它优先级高(z-index 10) */
629+
.diagram-error-overlay {
630+
position: absolute;
631+
inset: 0;
632+
display: grid;
633+
place-items: center;
634+
padding: 24px;
635+
z-index: 8;
636+
pointer-events: none;
637+
}
638+
639+
.diagram-error-overlay .error-message {
640+
pointer-events: auto;
641+
max-width: min(520px, 90%);
642+
background: var(--app-bg-overlay);
643+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
644+
}
645+
628646
/* ═══════════════════════════════════════
629647
图例
630648
═══════════════════════════════════════ */

src/App.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -586,12 +586,6 @@ const App = () => {
586586
height: "100%",
587587
}}
588588
>
589-
{error && (
590-
<div className="error-message" style={{ margin: "20px" }}>
591-
⚠️ {error}
592-
</div>
593-
)}
594-
595589
<div
596590
className={`diagram-container ${showBackground ? "" : "no-grid"}`}
597591
style={{ border: "none", borderRadius: 0 }}
@@ -629,6 +623,11 @@ const App = () => {
629623
<div className="spinner"></div>
630624
</div>
631625
)}
626+
{error && (
627+
<div className="diagram-error-overlay">
628+
<div className="error-message">⚠️ {error}</div>
629+
</div>
630+
)}
632631
<div
633632
ref={containerRef}
634633
style={{

src/builder.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,17 @@ const generateChenModelData = (
179179
// flip that lands the control points on opposite sides of the straight
180180
// line, forming a lens/eye shape with no gap at the vertices.
181181

182+
// 边标签来自解析器推断出的两端基数,缺省 N:1(DBML `>` / SQL FK 的隐含语义)。
183+
// 1:1(DBML `-`、或 FK 列为单列 PK / UNIQUE 的推断结果)会在两端都标 '1'。
184+
const fromLabel = rel.fromCardinality ?? 'N';
185+
const toLabel = rel.toCardinality ?? '1';
186+
182187
// Connect source entity (the one with the FK, 'many' side) to relationship
183188
edges.push({
184189
id: `edge-entity-${rel.from}-${relationshipId}-${relIndex}-1`,
185190
source: entityMap.get(rel.from),
186191
target: relationshipId,
187-
label: 'N',
192+
label: fromLabel,
188193
type: isSelfLoop ? 'self-loop-arc' : undefined,
189194
curveOffset: isSelfLoop ? 22 : undefined,
190195
style: {
@@ -208,7 +213,7 @@ const generateChenModelData = (
208213
id: `edge-${relationshipId}-entity-${rel.to}-${relIndex}-2`,
209214
source: relationshipId,
210215
target: entityMap.get(rel.to),
211-
label: '1',
216+
label: toLabel,
212217
type: isSelfLoop ? 'self-loop-arc' : undefined,
213218
curveOffset: isSelfLoop ? 22 : undefined,
214219
style: {

src/hooks/useGraph.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,35 @@ export function useGraph({ t, initialLang }: UseGraphOptions): UseGraphResult {
138138
return;
139139
}
140140

141-
// === 销毁旧图前,先把当前图作为旧 input 的快照存起来 ===
141+
// 解析放在保存旧图之前:解析失败时不应触发任何 IndexedDB 写入
142+
// (既不为新输入排程保存,也不为旧图落档),否则会把"用户随手清空 +
143+
// 粘错语法"的中间状态固化进历史。
144+
let parsedData = parseSQLTables(trimmed);
145+
if (parsedData.tables.length === 0) {
146+
parsedData = parseDBML(trimmed);
147+
}
148+
const { tables, relationships } = parsedData;
149+
150+
if (tables.length === 0) {
151+
// 无有效表:取消任何挂起的保存、清空画布并以遮罩形式呈现错误。
152+
// 不写 IndexedDB;不更新 lastInputRef,否则后续的"旧图保存"会以损坏
153+
// 的输入作 key 把上一次的有效图覆盖掉。
154+
cancelPendingPersist();
155+
if (graphRef.current) {
156+
graphRef.current.clear?.();
157+
graphRef.current.destroy?.();
158+
graphRef.current = null;
159+
}
160+
historyRef.current.reset();
161+
tablesDataRef.current = null;
162+
lastInputRef.current = "";
163+
setHasGraph(false);
164+
setError(cur.t.errNoTable);
165+
setLoading(false);
166+
return;
167+
}
168+
169+
// === 解析成功后,再把当前图作为旧 input 的快照存起来 ===
142170
// 这样用户在"上一份输入"上拖动后的位置不会因为重新生成而丢失。
143171
// 仅当存在旧图且旧 input 已落档(lastInputRef 非空)时才保存。
144172
if (graphRef.current && lastInputRef.current) {
@@ -155,19 +183,6 @@ export function useGraph({ t, initialLang }: UseGraphOptions): UseGraphResult {
155183

156184
lastInputRef.current = trimmed;
157185

158-
// Try parsing as SQL first, if it fails (no tables), try DBML.
159-
let parsedData = parseSQLTables(trimmed);
160-
if (parsedData.tables.length === 0) {
161-
parsedData = parseDBML(trimmed);
162-
}
163-
const { tables, relationships } = parsedData;
164-
165-
if (tables.length === 0) {
166-
setError(cur.t.errNoTable);
167-
setLoading(false);
168-
return;
169-
}
170-
171186
tablesDataRef.current = tables;
172187

173188
const { nodes, edges } = generateChenModelData(

src/parser/dbml.ts

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type {
2020
} from "../types";
2121

2222
const IDENT = String.raw`(?:\`[^\`]+\`|"[^"]+"|\[[^\]]+\]|[\w一-龥]+)`;
23+
// schema-qualified 标识符:a / a.b / a.b.c。每段都允许带引号 / 反引号 / 方括号。
24+
const QUALIFIED_IDENT = String.raw`${IDENT}(?:\.${IDENT})*`;
2325

2426
const cleanIdentifier = (raw: string): string => {
2527
const last = raw
@@ -251,15 +253,39 @@ interface RefTarget {
251253
}
252254

253255
const parseRefTarget = (raw: string): RefTarget | null => {
254-
const cleaned = raw.trim().replace(/^[(,]|[),]$/g, "");
256+
// 仅去掉首尾游离的逗号(来自 splitTopLevelCommas 拆开内联 ref 时残留)。
257+
// 不要再剥圆括号 —— 复合外键 `table.(col_a, col_b)` 的右括号会被误吃,
258+
// 历史上写过 `^[(,]|[),]$` 是按"防御性清洗"思路写的,现在已无必要。
259+
let cleaned = raw.trim().replace(/^,+|,+$/g, "").trim();
260+
// 去掉尾部 `[delete: cascade, update: cascade]` 这类设置块。
261+
// Ref: a.b > c.d [...] 时,右目标会粘上 `[...]`,必须先剥掉再分段。
262+
const lb = indexOfUnquoted(cleaned, "[");
263+
if (lb !== -1) {
264+
const rb = findMatchingBracket(cleaned, lb);
265+
if (rb !== -1) {
266+
cleaned = (cleaned.slice(0, lb) + " " + cleaned.slice(rb + 1)).trim();
267+
}
268+
}
255269
if (!cleaned) return null;
256270
const segs = cleaned
257271
.split(".")
258272
.map((s) => s.trim())
259273
.filter(Boolean)
260274
.map((s) => s.replace(/^[`"\[]|[`"\]]$/g, ""));
261275
if (segs.length < 2) return null;
262-
return { table: segs[segs.length - 2], column: segs[segs.length - 1] };
276+
// 复合列 `(col_a, col_b)` —— 去掉外层括号当作 label,避免出现 `(col_a, col_b)`
277+
// 这种带括号的边标签。table 与 column 仍按原始 segs 取,column 拿掉括号后
278+
// 用于显示。
279+
let column = segs[segs.length - 1];
280+
const composite = column.match(/^\(\s*([\s\S]+?)\s*\)$/);
281+
if (composite) {
282+
column = composite[1]
283+
.split(",")
284+
.map((s) => s.trim())
285+
.filter(Boolean)
286+
.join(", ");
287+
}
288+
return { table: segs[segs.length - 2], column };
263289
};
264290

265291
const parseInlineRef = (
@@ -315,13 +341,16 @@ const parseColumnLine = (line: string): ColumnLineResult => {
315341
const type = m[2].trim().replace(/\s+/g, " ");
316342

317343
let isPrimaryKey = false;
344+
let isUnique = false;
318345
let inlineRef: ColumnLineResult["inlineRef"] = null;
319346
let comment: string | undefined;
320347

321348
if (attrsRaw) {
322349
for (const attr of parseColumnAttrs(attrsRaw)) {
323350
if (attr.key === "pk" || attr.key === "primary key") {
324351
isPrimaryKey = true;
352+
} else if (attr.key === "unique") {
353+
isUnique = true;
325354
} else if (attr.key === "ref" && attr.value) {
326355
const r = parseInlineRef(attr.value);
327356
if (r) inlineRef = r;
@@ -332,6 +361,7 @@ const parseColumnLine = (line: string): ColumnLineResult => {
332361
}
333362

334363
const column: ParsedColumn = { name, type, isPrimaryKey };
364+
if (isUnique) column.isUnique = true;
335365
if (comment !== undefined) column.comment = comment;
336366
return { column, inlineRef };
337367
};
@@ -383,16 +413,29 @@ interface TopStatement {
383413
}
384414

385415
const classifyHeader = (header: string): TopStatement["kind"] => {
416+
// 注意:先于 `Table\b` 判 TablePartial / TableGroup,否则前者会被吞。
417+
if (/^TablePartial\b/i.test(header)) return "unknown";
418+
if (/^TableGroup\b/i.test(header)) return "tablegroup";
386419
if (/^Table\b/i.test(header)) return "table";
387-
if (/^Ref\b\s*(?:[\w-]+\s*)?:/i.test(header)) return "ref";
420+
// Ref 短句:`Ref:` 或 `Ref name:`,可能换行后才进入正文
421+
if (/^Ref\b[^{]*:/i.test(header)) return "ref";
388422
if (/^Ref\b/i.test(header)) return "refblock";
389423
if (/^Enum\b/i.test(header)) return "enum";
390424
if (/^Project\b/i.test(header)) return "project";
391-
if (/^TableGroup\b/i.test(header)) return "tablegroup";
425+
if (/^DiagramView\b/i.test(header)) return "unknown";
426+
if (/^records\b/i.test(header)) return "unknown";
427+
if (/^Note\b/i.test(header)) return "unknown";
392428
return "unknown";
393429
};
394430

395-
const TOP_KEYWORD_RE = /^(Table|Ref|Project|Enum|TableGroup)\b/i;
431+
// 把 TablePartial / DiagramView / records / Note 也纳入识别:
432+
// 否则 findNextKeyword 会逐字符地走过它们的 body 内容(包含 jsonb 字面量、
433+
// 反引号表达式、Markdown 文本等),有概率撞上像 `TableGroup` 这样的字眼并误识别。
434+
// 这里识别后仍然分类为 'unknown' 在主循环里跳过。
435+
// 顺序:长前缀放在前面(TablePartial 在 Table 前,TableGroup 在 Table 前),
436+
// 否则 `Table\b` 会优先返回但匹配失败浪费一次。
437+
const TOP_KEYWORD_RE =
438+
/^(TablePartial|TableGroup|Table|Ref|Project|Enum|DiagramView|records|Note)\b/i;
396439

397440
// 从 from 开始找下一处可能开启顶层语句的关键字位置(必须在词边界,
398441
// 且当前不在字符串字面量里)。找不到返回 -1。
@@ -426,20 +469,40 @@ const tokenizeTopLevel = (src: string): TopStatement[] => {
426469

427470
let braceIdx = -1;
428471
let lineEndIdx = -1;
472+
let bracketDepth = 0;
473+
let seenOperator = false;
429474
let j = i;
430475
while (j < n) {
431476
const ch = src[j];
432477
if (ch === "'" || ch === '"' || ch === "`") {
433478
j = skipString(src, j);
434479
continue;
435480
}
436-
if (ch === "{") {
481+
if (ch === "[") {
482+
bracketDepth++;
483+
j++;
484+
continue;
485+
}
486+
if (ch === "]") {
487+
if (bracketDepth > 0) bracketDepth--;
488+
j++;
489+
continue;
490+
}
491+
if (ch === "{" && bracketDepth === 0) {
437492
braceIdx = j;
438493
break;
439494
}
440-
if (ch === "\n") {
495+
// Ref 短句的关系运算符(仅在顶层、未在 [...] 设置块里时计数)。
496+
// 多行 `Ref name:\n a > b [...]` 形式时:`:` 后面的换行不能立刻
497+
// 终止语句 —— 至少要等到运算符出现,才认为左 / 右目标都已就位。
498+
if (
499+
bracketDepth === 0 &&
500+
(ch === "<" || ch === ">" || ch === "-")
501+
) {
502+
seenOperator = true;
503+
}
504+
if (ch === "\n" && bracketDepth === 0 && seenOperator) {
441505
const head = src.slice(startIdx, j).trim();
442-
// Ref 短语句没有块;遇到换行就到此为止
443506
if (/^Ref\b[^{]*:/i.test(head)) {
444507
lineEndIdx = j;
445508
break;
@@ -485,7 +548,7 @@ const parseTableHeader = (
485548
}
486549
const m = h.match(
487550
new RegExp(
488-
String.raw`^Table\s+(${IDENT})(?:\s+as\s+(${IDENT}))?\s*$`,
551+
String.raw`^Table\s+(${QUALIFIED_IDENT})(?:\s+as\s+(${IDENT}))?\s*$`,
489552
"i",
490553
),
491554
);
@@ -496,15 +559,39 @@ const parseTableHeader = (
496559
};
497560
};
498561

562+
// DBML 关系运算符 → 两端基数。
563+
// `>` many-to-one (默认 FK 方向)
564+
// `<` one-to-many
565+
// `-` one-to-one
566+
// `<>` many-to-many
567+
const opToCardinality = (
568+
op: string,
569+
): { from: import("../types").Cardinality; to: import("../types").Cardinality } => {
570+
switch (op) {
571+
case "<":
572+
return { from: "1", to: "N" };
573+
case "-":
574+
return { from: "1", to: "1" };
575+
case "<>":
576+
return { from: "N", to: "N" };
577+
case ">":
578+
default:
579+
return { from: "N", to: "1" };
580+
}
581+
};
582+
499583
const addRelationship = (
500584
ref: ParsedRefStatement,
501585
relationships: ParsedRelationship[],
502586
tableByName: Map<string, ParsedTable>,
503587
): void => {
588+
const card = opToCardinality(ref.op);
504589
relationships.push({
505590
from: ref.from.table,
506591
to: ref.to.table,
507592
label: ref.from.column,
593+
fromCardinality: card.from,
594+
toCardinality: card.to,
508595
});
509596
const t = tableByName.get(ref.from.table);
510597
if (t) {
@@ -535,9 +622,18 @@ export const parseDBML = (dbml: string): ParseResult => {
535622
for (const line of splitLogicalLines(stmt.body)) {
536623
const trimmed = line.trim();
537624
if (!trimmed) continue;
538-
// 跳过嵌套块:Note { ... } / Note: '...' / indexes { ... }
625+
// 跳过嵌套块 / 非列声明:
626+
// Note { ... } / Note: '...'
627+
// indexes { ... }
628+
// checks { ... } DBML 校验约束块
629+
// records { ... } / records (cols) { ... } 插桩示例数据块
630+
// ~partial_name TablePartial 注入;不展开,仅跳过
631+
// 不跳过会被 parseColumnLine 错认为以 `checks` / `records` 命名的列。
539632
if (/^Note\s*[:{]/i.test(trimmed)) continue;
540633
if (/^indexes\s*\{/i.test(trimmed)) continue;
634+
if (/^checks\s*\{/i.test(trimmed)) continue;
635+
if (/^records\b/i.test(trimmed)) continue;
636+
if (trimmed.startsWith("~")) continue;
541637

542638
const { column, inlineRef } = parseColumnLine(trimmed);
543639
if (!column) continue;
@@ -548,10 +644,13 @@ export const parseDBML = (dbml: string): ParseResult => {
548644
referencedTable: inlineRef.target.table,
549645
referencedColumn: inlineRef.target.column,
550646
});
647+
const card = opToCardinality(inlineRef.op);
551648
relationships.push({
552649
from: head.name,
553650
to: inlineRef.target.table,
554651
label: column.name,
652+
fromCardinality: card.from,
653+
toCardinality: card.to,
555654
});
556655
}
557656
columns.push(column);
@@ -587,5 +686,28 @@ export const parseDBML = (dbml: string): ParseResult => {
587686
// enum / project / tablegroup / unknown:忽略
588687
}
589688

689+
// 基数推断:当关系是默认的 N:1(来自 `>` 或缺省),但 FK 列在 from 表上
690+
// 是单列主键或带 unique 约束时,把 from 端升级为 "1" —— 这才是 1:1。
691+
// 例:
692+
// Table user_profiles { user_id bigint [pk, ref: > users.id] } // 推断 1:1
693+
// Table payments { order_id bigint [unique]; ... }
694+
// Ref: payments.order_id > orders.id // 推断 1:1
695+
// 对显式写 `<` / `-` / `<>` 的关系不做改动,尊重作者意图。
696+
// label 含逗号说明是复合 FK,单列推断不适用,跳过。
697+
for (const rel of relationships) {
698+
if (rel.fromCardinality !== "N" || rel.toCardinality !== "1") continue;
699+
if (rel.label.includes(",")) continue;
700+
const fromTable = tableByName.get(rel.from);
701+
if (!fromTable) continue;
702+
const col = fromTable.columns.find((c) => c.name === rel.label);
703+
if (!col) continue;
704+
const isOnlySinglePk =
705+
fromTable.primaryKeys.length === 1 &&
706+
fromTable.primaryKeys[0] === col.name;
707+
if (col.isUnique || isOnlySinglePk) {
708+
rel.fromCardinality = "1";
709+
}
710+
}
711+
590712
return { tables, relationships };
591713
};

0 commit comments

Comments
 (0)