Skip to content

Commit ab9f541

Browse files
ystemsrxclaude
andcommitted
feat(comments): apply COMMENT to entities and relationships, toggle in place
- Parse table-level COMMENT (MySQL CREATE TABLE ... COMMENT='...') and DBML Table Note: '...' / Note { '...' }; relationships pick up Ref [note: '...'] or fall back to the FK column's COMMENT. - generateChenModelData uses pickLabel for entities and relationships, embedding nameLabel/commentLabel on every node so the toggle can switch without regenerating the graph. - setShowComment now mutates labels in place and refreshes connected edges instead of calling handleGenerate, preserving layout. - Rewrite custom-node update for entity/attribute/relationship to mutate shapes in place: keeps G6's keyShape reference valid, recomputes size on label change, and toggles the PK underline. Fixes rectangle edits not showing the new label and edges not reconnecting until the node is dragged. - Editor finishEditing refreshes edges connected to the edited node so endpoints follow the new bbox immediately. - Rename Chinese label "属性显示注释" -> "展示 COMMENT". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d765fa7 commit ab9f541

8 files changed

Lines changed: 584 additions & 182 deletions

File tree

src/builder.ts

Lines changed: 437 additions & 168 deletions
Large diffs are not rendered by default.

src/editor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,18 @@ export function setupNodeDoubleClickEdit(
266266
}
267267
}
268268
graph.updateItem(editingNode, { label: newLabel });
269+
270+
// 节点 label 变化会改变其包围盒尺寸,但 G6 不会主动让连线重新跑
271+
// getLinkPoint —— 必须显式刷新与该节点相连的边,否则要等用户拖
272+
// 一下节点连线才会贴上来。
273+
const nodeId = model.id;
274+
graph.getEdges().forEach((edge) => {
275+
const em = edge.getModel();
276+
if (em.source === nodeId || em.target === nodeId) {
277+
graph.updateItem(edge, {});
278+
}
279+
});
280+
if (graph.refresh) graph.refresh();
269281
}
270282
}
271283

src/hooks/useGraph.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,10 +326,32 @@ export function useGraph({ t, initialLang }: UseGraphOptions): UseGraphResult {
326326

327327
const setShowComment = (next: boolean) => {
328328
setShowCommentState(next);
329-
if (hasGraph && lastInputRef.current) {
330-
// 标签内容变化需要重新生成(节点 label 不可热更新)
331-
handleGenerate({ showComment: next });
332-
}
329+
const graph = graphRef.current;
330+
if (!hasGraph || !graph || graph.destroyed) return;
331+
// 不再走 handleGenerate 重建图;直接用每个节点上预先存的 nameLabel /
332+
// commentLabel 切换 label 字段。布局保持原样,连线在 builder 的 update
333+
// 里会随节点尺寸变化自动重画(连带的边刷新仍然显式做一次以兜底)。
334+
graph.setAutoPaint(false);
335+
graph.getNodes().forEach((node) => {
336+
const m = node.getModel() as ERNodeModel & {
337+
nameLabel?: string;
338+
commentLabel?: string;
339+
};
340+
const nameLabel = m.nameLabel;
341+
const commentLabel = m.commentLabel;
342+
if (nameLabel === undefined && commentLabel === undefined) return;
343+
const target = next
344+
? commentLabel || nameLabel || m.label
345+
: nameLabel || m.label;
346+
if (target !== undefined && target !== m.label) {
347+
graph.updateItem(node, { label: target });
348+
}
349+
});
350+
// 节点尺寸可能因 label 变化而改变,强制让所有边按新 bbox 重算端点。
351+
graph.getEdges().forEach((edge) => graph.updateItem(edge, {}));
352+
if (graph.refresh) graph.refresh();
353+
graph.paint();
354+
graph.setAutoPaint(true);
333355
};
334356

335357
const setHideFields = (next: boolean) => {

src/i18n.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const I18N = {
77
pageTitle: "SQL/DBML2ER",
88
cardInputTitle: "SQL / DBML",
99
cardPreviewTitle: "ER 图预览",
10-
showComment: "属性显示注释",
10+
showComment: "展示 COMMENT",
1111
hideFields: "隐藏属性",
1212
editorPlaceholder: "在此处粘贴您的 CREATE TABLE 或 DBML 语句...",
1313
btnGenerate: "⚡ 生成 ER 图",

src/parser/dbml.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,38 @@ interface ParsedRefStatement {
370370
from: RefTarget;
371371
to: RefTarget;
372372
op: string;
373+
comment?: string;
373374
}
374375

375-
const parseRefBody = (body: string): ParsedRefStatement | null => {
376+
// Ref 顶层 settings 块 `[delete: cascade, note: 'xxx']` 中的 note 是关系注释。
377+
// 拆出来:返回 (剥掉外层 [...] 后的 body, 提取到的 note 字符串)。
378+
const stripRefSettings = (
379+
body: string,
380+
): { body: string; comment?: string } => {
381+
let cleaned = body;
382+
let comment: string | undefined;
383+
const lb = indexOfUnquoted(cleaned, "[");
384+
if (lb !== -1) {
385+
const rb = findMatchingBracket(cleaned, lb);
386+
if (rb !== -1) {
387+
const inner = cleaned.slice(lb + 1, rb);
388+
// 注意 parseRefTarget 也会剥掉粘在右目标后面的 [...];这里 stripRefSettings
389+
// 只是把"留在 body 里的 settings 文字"再抽一层 note 出来供关系节点显示。
390+
for (const attr of splitTopLevelCommas(inner)) {
391+
const colon = indexOfUnquoted(attr, ":");
392+
if (colon === -1) continue;
393+
const key = attr.slice(0, colon).trim().toLowerCase();
394+
const value = attr.slice(colon + 1).trim();
395+
if (key === "note") comment = stripQuotes(value);
396+
}
397+
cleaned = (cleaned.slice(0, lb) + " " + cleaned.slice(rb + 1)).trim();
398+
}
399+
}
400+
return { body: cleaned, comment };
401+
};
402+
403+
const parseRefBody = (rawBody: string): ParsedRefStatement | null => {
404+
const { body, comment } = stripRefSettings(rawBody);
376405
let i = 0;
377406
while (i < body.length) {
378407
const ch = body[i];
@@ -385,14 +414,17 @@ const parseRefBody = (body: string): ParsedRefStatement | null => {
385414
const right = body.slice(i + 2).trim();
386415
const from = parseRefTarget(left);
387416
const to = parseRefTarget(right);
388-
return from && to ? { from, to, op: "<>" } : null;
417+
return from && to
418+
? { from, to, op: "<>", ...(comment ? { comment } : {}) }
419+
: null;
389420
}
390421
if (ch === "<" || ch === ">" || ch === "-") {
391422
const left = body.slice(0, i).trim();
392423
const right = body.slice(i + 1).trim();
393424
const from = parseRefTarget(left);
394425
const to = parseRefTarget(right);
395-
if (from && to) return { from, to, op: ch };
426+
if (from && to)
427+
return { from, to, op: ch, ...(comment ? { comment } : {}) };
396428
}
397429
i++;
398430
}
@@ -592,6 +624,7 @@ const addRelationship = (
592624
label: ref.from.column,
593625
fromCardinality: card.from,
594626
toCardinality: card.to,
627+
...(ref.comment ? { comment: ref.comment } : {}),
595628
});
596629
const t = tableByName.get(ref.from.table);
597630
if (t) {
@@ -604,6 +637,29 @@ const addRelationship = (
604637
}
605638
};
606639

640+
// 从表 body 中提取 `Note: '...'` 或 `Note { '...' }` 作为表级注释。
641+
// 不修改原 body —— 调用方会照常用 splitLogicalLines 跳过 Note 行。
642+
const extractTableNote = (body: string): string | undefined => {
643+
const lines = splitLogicalLines(body);
644+
for (const raw of lines) {
645+
const line = raw.trim();
646+
if (!/^Note\s*[:{]/i.test(line)) continue;
647+
const afterNote = line.slice(4).trim();
648+
if (afterNote.startsWith(":")) {
649+
const v = afterNote.slice(1).trim();
650+
if (v) return stripQuotes(v);
651+
continue;
652+
}
653+
if (afterNote.startsWith("{")) {
654+
const close = findMatchingBrace(afterNote, 0);
655+
if (close === -1) continue;
656+
const inner = afterNote.slice(1, close).trim();
657+
if (inner) return stripQuotes(inner);
658+
}
659+
}
660+
return undefined;
661+
};
662+
607663
export const parseDBML = (dbml: string): ParseResult => {
608664
const tables: ParsedTable[] = [];
609665
const relationships: ParsedRelationship[] = [];
@@ -656,12 +712,14 @@ export const parseDBML = (dbml: string): ParseResult => {
656712
columns.push(column);
657713
}
658714

715+
const tableNote = extractTableNote(stmt.body);
659716
const table: ParsedTable = {
660717
name: head.name,
661718
alias: head.alias,
662719
columns,
663720
primaryKeys,
664721
foreignKeys,
722+
...(tableNote ? { comment: tableNote } : {}),
665723
};
666724
tables.push(table);
667725
tableByName.set(head.name, table);

src/parser/sql.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ const splitTopLevelComma = (body: string) => {
158158
return parts;
159159
};
160160

161-
const extractMainBody = (statement: string) => {
161+
const extractMainBody = (
162+
statement: string,
163+
): { body: string; suffix: string } | null => {
162164
const openParenIndex = statement.indexOf("(");
163165
if (openParenIndex === -1) return null;
164166
let closeParenIndex = -1;
@@ -181,9 +183,18 @@ const extractMainBody = (statement: string) => {
181183
depth--;
182184
}
183185
}
184-
return closeParenIndex === -1
185-
? null
186-
: statement.substring(openParenIndex + 1, closeParenIndex);
186+
if (closeParenIndex === -1) return null;
187+
return {
188+
body: statement.substring(openParenIndex + 1, closeParenIndex),
189+
suffix: statement.substring(closeParenIndex + 1),
190+
};
191+
};
192+
193+
// 解析 CREATE TABLE 末尾的表级 COMMENT。MySQL 写法 `... ) ENGINE=InnoDB COMMENT='xxx'`
194+
// 或 `COMMENT 'xxx'`;PostgreSQL 用 COMMENT ON TABLE 单独语句,这里不处理。
195+
const extractTableComment = (suffix: string): string | undefined => {
196+
const m = suffix.match(/\bCOMMENT\s*=?\s*'((?:[^'\\]|''|\\.)*)'/i);
197+
return m ? m[1].replace(/''/g, "'") : undefined;
187198
};
188199

189200
const parseIdentifierList = (text: string) =>
@@ -217,8 +228,10 @@ export const parseSQLTables = (sql: string): ParseResult => {
217228
// FK 都继承自父表。强行解析会得到一个空节点漂在图上,干扰阅读。
218229
if (/\bPARTITION\s+OF\b/i.test(statement)) return;
219230

220-
const tableBody = extractMainBody(statement);
221-
if (!tableBody) return;
231+
const extracted = extractMainBody(statement);
232+
if (!extracted) return;
233+
const tableBody = extracted.body;
234+
const tableComment = extractTableComment(extracted.suffix);
222235

223236
const columns: ParsedColumn[] = [];
224237
const primaryKeys: string[] = [];
@@ -296,6 +309,7 @@ export const parseSQLTables = (sql: string): ParseResult => {
296309
columns,
297310
primaryKeys,
298311
foreignKeys,
312+
...(tableComment ? { comment: tableComment } : {}),
299313
});
300314

301315
foreignKeys.forEach((fk) => {
@@ -312,6 +326,9 @@ export const parseSQLTables = (sql: string): ParseResult => {
312326
label: fk.column,
313327
fromCardinality,
314328
toCardinality: "1",
329+
// SQL 没有原生关系级注释,但用户的认知里"FK 注释 == 关系注释"是
330+
// 自然的:用 FK 列上的 COMMENT 'xxx' 作为关系的注释来源。
331+
...(fkCol?.comment ? { comment: fkCol.comment } : {}),
315332
});
316333
});
317334
});

src/test/repro.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseSQLTables } from "../parser/sql";
3+
import { parseDBML } from "../parser/dbml";
4+
import { generateChenModelData } from "../builder";
5+
6+
describe("user-input repro", () => {
7+
const inputs = [
8+
`CREATE TABLE users (id INT PRIMARY KEY) COMMENT='users table';`,
9+
`CREATE TABLE u (id INT PRIMARY KEY COMMENT 'pk', name VARCHAR(50) COMMENT 'user name');`,
10+
`CREATE TABLE p (id INT PRIMARY KEY); CREATE TABLE c (id INT PRIMARY KEY, p_id INT COMMENT 'parent', FOREIGN KEY (p_id) REFERENCES p(id));`,
11+
`Table u { id int [pk] Note: 'a note' }`,
12+
`Table u { id int [pk] }\nTable p { uid int [ref: > u.id, note: 'belongs'] }`,
13+
`Table u { id int [pk] }\nTable p { uid int }\nRef: p.uid > u.id [note: 'fk note']`,
14+
];
15+
for (const sql of inputs) {
16+
it(`parses+builds: ${sql.slice(0,40)}`, () => {
17+
let parsed = parseSQLTables(sql);
18+
if (parsed.tables.length === 0) parsed = parseDBML(sql);
19+
expect(() => generateChenModelData(parsed.tables, parsed.relationships, true, "comment", false)).not.toThrow();
20+
});
21+
}
22+
});

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface ParsedTable {
2626
columns: ParsedColumn[];
2727
primaryKeys: string[];
2828
foreignKeys: ParsedForeignKey[];
29+
comment?: string;
2930
}
3031

3132
export interface ParsedRelationship {
@@ -36,6 +37,7 @@ export interface ParsedRelationship {
3637
// 这是 SQL FK 与 DBML `>` 的隐含语义。
3738
fromCardinality?: Cardinality;
3839
toCardinality?: Cardinality;
40+
comment?: string;
3941
}
4042

4143
export interface ParseResult {

0 commit comments

Comments
 (0)