mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 22:42:55 +08:00
🐛 fix(table-designer): 突出显示 SQL 变更行
- 识别新增、删除、重命名、属性修改等变更 SQL 行 - 使用 Monaco decorations 仅标记变更行,保留基础 SQL 语法高亮 - 补充变更行识别与装饰渲染回归测试 Refs #324
This commit is contained in:
@@ -2,19 +2,46 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import TableDesignerSqlPreview from './TableDesignerSqlPreview';
|
||||
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
|
||||
|
||||
const mockMonaco = {
|
||||
Range: class {
|
||||
startLineNumber: number;
|
||||
startColumn: number;
|
||||
endLineNumber: number;
|
||||
endColumn: number;
|
||||
|
||||
constructor(
|
||||
startLineNumber: number,
|
||||
startColumn: number,
|
||||
endLineNumber: number,
|
||||
endColumn: number,
|
||||
) {
|
||||
this.startLineNumber = startLineNumber;
|
||||
this.startColumn = startColumn;
|
||||
this.endLineNumber = endLineNumber;
|
||||
this.endColumn = endColumn;
|
||||
}
|
||||
},
|
||||
editor: {
|
||||
defineTheme: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockEditor = {
|
||||
deltaDecorations: vi.fn(() => ['decoration-1']),
|
||||
getModel: vi.fn(() => ({
|
||||
getLineCount: () => 5,
|
||||
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({
|
||||
beforeMount,
|
||||
defaultLanguage,
|
||||
language,
|
||||
onMount,
|
||||
options,
|
||||
theme,
|
||||
value,
|
||||
@@ -22,11 +49,13 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
beforeMount?: (monaco: any) => void;
|
||||
defaultLanguage?: string;
|
||||
language?: string;
|
||||
onMount?: (editor: any, monaco: any) => void;
|
||||
options?: Record<string, any>;
|
||||
theme?: string;
|
||||
value?: string;
|
||||
}) => {
|
||||
beforeMount?.(mockMonaco);
|
||||
onMount?.(mockEditor, mockMonaco);
|
||||
return (
|
||||
<div
|
||||
data-default-language={defaultLanguage}
|
||||
@@ -43,6 +72,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
|
||||
describe('TableDesignerSqlPreview', () => {
|
||||
beforeEach(() => {
|
||||
mockEditor.deltaDecorations.mockClear();
|
||||
mockMonaco.editor.defineTheme.mockClear();
|
||||
});
|
||||
|
||||
@@ -78,6 +108,56 @@ describe('TableDesignerSqlPreview', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
|
||||
const highlights = resolveSqlChangeHighlights([
|
||||
'ALTER TABLE "users"',
|
||||
'ADD COLUMN "age" int NULL;',
|
||||
'ALTER TABLE "users"',
|
||||
'RENAME COLUMN "name" TO "display_name";',
|
||||
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
|
||||
].join('\n'));
|
||||
|
||||
expect(highlights).toEqual([
|
||||
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
|
||||
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds Monaco decorations to changed SQL lines only', () => {
|
||||
renderToStaticMarkup(
|
||||
<TableDesignerSqlPreview
|
||||
sql={[
|
||||
'ALTER TABLE "users"',
|
||||
'ADD COLUMN "age" int NULL;',
|
||||
'ALTER TABLE "users"',
|
||||
'DROP COLUMN "legacy_name";',
|
||||
].join('\n')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
|
||||
options: expect.objectContaining({
|
||||
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
|
||||
isWholeLine: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
|
||||
options: expect.objectContaining({
|
||||
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
|
||||
isWholeLine: true,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
|
||||
expect(firstDecorationCall[1]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses the dark SQL preview theme when dark mode is enabled', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Editor, { type BeforeMount } from '@monaco-editor/react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
|
||||
interface TableDesignerSqlPreviewProps {
|
||||
sql: string;
|
||||
@@ -6,9 +7,79 @@ interface TableDesignerSqlPreviewProps {
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export type SqlChangeHighlightKind =
|
||||
| 'add'
|
||||
| 'comment'
|
||||
| 'constraint'
|
||||
| 'create'
|
||||
| 'drop'
|
||||
| 'modify'
|
||||
| 'rename';
|
||||
|
||||
export interface SqlChangeHighlight {
|
||||
line: string;
|
||||
lineNumber: number;
|
||||
kind: SqlChangeHighlightKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light';
|
||||
const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark';
|
||||
|
||||
const CHANGE_LINE_RULES: Array<{
|
||||
kind: SqlChangeHighlightKind;
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}> = [
|
||||
{ kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i },
|
||||
{ kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i },
|
||||
{ kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i },
|
||||
{ kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i },
|
||||
{ kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i },
|
||||
{ kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i },
|
||||
];
|
||||
|
||||
const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i;
|
||||
|
||||
const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('--')) return null;
|
||||
return {
|
||||
line,
|
||||
lineNumber,
|
||||
kind: 'create',
|
||||
label: '新建表结构',
|
||||
};
|
||||
};
|
||||
|
||||
const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('--')) return null;
|
||||
|
||||
const matchedRule = CHANGE_LINE_RULES.find((rule) => rule.pattern.test(trimmed));
|
||||
if (!matchedRule) return null;
|
||||
|
||||
return {
|
||||
line,
|
||||
lineNumber,
|
||||
kind: matchedRule.kind,
|
||||
label: matchedRule.label,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => {
|
||||
const lines = sql.split(/\r?\n/);
|
||||
const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line));
|
||||
|
||||
return lines
|
||||
.map((line, index) => (
|
||||
isCreateTableSql
|
||||
? getCreateTableLineHighlight(line, index + 1)
|
||||
: getAlterLineHighlight(line, index + 1)
|
||||
))
|
||||
.filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight));
|
||||
};
|
||||
|
||||
const registerSqlPreviewThemes: BeforeMount = (monaco) => {
|
||||
monaco.editor.defineTheme(SQL_PREVIEW_LIGHT_THEME, {
|
||||
base: 'vs',
|
||||
@@ -49,40 +120,133 @@ const registerSqlPreviewThemes: BeforeMount = (monaco) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getLineDecorationClassName = (kind: SqlChangeHighlightKind): string =>
|
||||
`gonavi-sql-preview-change-line gonavi-sql-preview-change-line-${kind}`;
|
||||
|
||||
const getLineDecorationMarkerClassName = (kind: SqlChangeHighlightKind): string =>
|
||||
`gonavi-sql-preview-change-marker gonavi-sql-preview-change-marker-${kind}`;
|
||||
|
||||
const TableDesignerSqlPreview: React.FC<TableDesignerSqlPreviewProps> = ({
|
||||
sql,
|
||||
darkMode = false,
|
||||
height = '360px',
|
||||
}) => (
|
||||
<div
|
||||
data-table-designer-sql-preview="true"
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid #333' : '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
beforeMount={registerSqlPreviewThemes}
|
||||
defaultLanguage="sql"
|
||||
height={height}
|
||||
language="sql"
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 8, bottom: 8 },
|
||||
readOnly: true,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}) => {
|
||||
const decorationIdsRef = useRef<string[]>([]);
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]);
|
||||
|
||||
const applyChangeDecorations = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
const model = editor?.getModel?.();
|
||||
if (!editor || !monaco || !model) return;
|
||||
|
||||
const lineCount = model.getLineCount();
|
||||
const decorations = changeHighlights
|
||||
.filter((highlight) => highlight.lineNumber <= lineCount)
|
||||
.map((highlight) => {
|
||||
const endColumn = Math.max(1, model.getLineMaxColumn(highlight.lineNumber));
|
||||
return {
|
||||
range: new monaco.Range(highlight.lineNumber, 1, highlight.lineNumber, endColumn),
|
||||
options: {
|
||||
className: getLineDecorationClassName(highlight.kind),
|
||||
glyphMarginClassName: getLineDecorationMarkerClassName(highlight.kind),
|
||||
hoverMessage: { value: highlight.label },
|
||||
isWholeLine: true,
|
||||
linesDecorationsClassName: getLineDecorationMarkerClassName(highlight.kind),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, decorations);
|
||||
}, [changeHighlights]);
|
||||
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
applyChangeDecorations();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyChangeDecorations();
|
||||
}, [applyChangeDecorations, sql]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-table-designer-sql-preview="true"
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: darkMode ? '1px solid #333' : '1px solid #eee',
|
||||
}}
|
||||
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
|
||||
value={sql}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
.gonavi-sql-preview-change-line {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.gonavi-sql-preview-change-line-add,
|
||||
.gonavi-sql-preview-change-line-create {
|
||||
background: rgba(22, 163, 74, 0.14);
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
.gonavi-sql-preview-change-line-drop {
|
||||
background: rgba(220, 38, 38, 0.14);
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
.gonavi-sql-preview-change-line-modify,
|
||||
.gonavi-sql-preview-change-line-rename,
|
||||
.gonavi-sql-preview-change-line-constraint,
|
||||
.gonavi-sql-preview-change-line-comment {
|
||||
background: rgba(217, 119, 6, 0.16);
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
.gonavi-sql-preview-change-marker {
|
||||
width: 4px !important;
|
||||
margin-left: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.gonavi-sql-preview-change-marker-add,
|
||||
.gonavi-sql-preview-change-marker-create {
|
||||
background: #16a34a;
|
||||
}
|
||||
.gonavi-sql-preview-change-marker-drop {
|
||||
background: #dc2626;
|
||||
}
|
||||
.gonavi-sql-preview-change-marker-modify,
|
||||
.gonavi-sql-preview-change-marker-rename,
|
||||
.gonavi-sql-preview-change-marker-constraint,
|
||||
.gonavi-sql-preview-change-marker-comment {
|
||||
background: #d97706;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Editor
|
||||
beforeMount={registerSqlPreviewThemes}
|
||||
defaultLanguage="sql"
|
||||
height={height}
|
||||
language="sql"
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
|
||||
fontSize: 13,
|
||||
glyphMargin: true,
|
||||
lineNumbers: 'on',
|
||||
lineDecorationsWidth: 14,
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 8, bottom: 8 },
|
||||
readOnly: true,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
|
||||
value={sql}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableDesignerSqlPreview;
|
||||
|
||||
Reference in New Issue
Block a user