diff --git a/frontend/src/components/TableDesignerSqlPreview.test.tsx b/frontend/src/components/TableDesignerSqlPreview.test.tsx index 823888f7..b05c9093 100644 --- a/frontend/src/components/TableDesignerSqlPreview.test.tsx +++ b/frontend/src/components/TableDesignerSqlPreview.test.tsx @@ -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; theme?: string; value?: string; }) => { beforeMount?.(mockMonaco); + onMount?.(mockEditor, mockMonaco); return (
({ 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( + , + ); + + 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( , diff --git a/frontend/src/components/TableDesignerSqlPreview.tsx b/frontend/src/components/TableDesignerSqlPreview.tsx index 2ba56c5e..2622d0d7 100644 --- a/frontend/src/components/TableDesignerSqlPreview.tsx +++ b/frontend/src/components/TableDesignerSqlPreview.tsx @@ -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 = ({ sql, darkMode = false, height = '360px', -}) => ( -
- { + const decorationIdsRef = useRef([]); + const editorRef = useRef(null); + const monacoRef = useRef(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 ( +
-
-); + > + + +
+ ); +}; export default TableDesignerSqlPreview;