From f0ffef2080d1879c87133b14d17091ea5b7db4fa Mon Sep 17 00:00:00 2001 From: kunghim Date: Thu, 6 Aug 2026 18:13:07 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E7=BB=93=E6=9E=9C=E5=AD=97=E6=AE=B5=E6=8F=92?= =?UTF-8?q?=E5=85=A5=20SQL=20(#848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 9 + frontend/src/components/DataGridCore.tsx | 21 +- .../QueryEditor.results-and-drop.test.tsx | 83 +++ frontend/src/components/QueryEditor.tsx | 176 ++++++- frontend/src/utils/sqlFieldDrop.test.ts | 149 ++++++ frontend/src/utils/sqlFieldDrop.ts | 474 ++++++++++++++++++ 6 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 frontend/src/utils/sqlFieldDrop.test.ts create mode 100644 frontend/src/utils/sqlFieldDrop.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index cb6bfe7c..9b348081 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2899,6 +2899,11 @@ body[data-platform='windows'] .titlebar-window-controls { color: #0f766e; } +.gonavi-query-editor-field-drop-anchor { + background-color: rgba(0, 0, 0, 0.12); + border-radius: 2px; +} + .gonavi-query-editor-db-token { color: #7c3aed; } @@ -2915,6 +2920,10 @@ body[data-theme='dark'] .gonavi-query-editor-column-token { color: #5eead4; } +body[data-theme='dark'] .gonavi-query-editor-field-drop-anchor { + background-color: rgba(255, 255, 255, 0.16); +} + body[data-theme='dark'] .gonavi-query-editor-db-token { color: #c4b5fd; } diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index c7cdd477..b0b688ed 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -75,6 +75,8 @@ import { import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { formatMongoValueForDisplay } from '../utils/mongodb'; +import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; +import { SQL_FIELD_DRAG_MIME } from '../utils/sqlFieldDrop'; import { TEMPORAL_FORMATS, formatFromDayjs, @@ -877,7 +879,24 @@ const SortableHeaderCell: React.FC = React.memo((props) }} > -
+
{ + const columnName = String(id || '').trim(); + if (!columnName || !event.dataTransfer) return; + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'copy'; + const payload = encodeSidebarSqlEditorDragPayload({ + text: columnName, + nodeType: 'column', + }); + event.dataTransfer.setData(SIDEBAR_SQL_EDITOR_DRAG_MIME, payload); + event.dataTransfer.setData(SQL_FIELD_DRAG_MIME, columnName); + event.dataTransfer.setData('text/plain', columnName); + }} + >
{children}
diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index da3a06f8..202d2118 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -4299,6 +4299,89 @@ describe('QueryEditor external SQL save', () => { })); }); + it('projects field drops from editor whitespace by x coordinate and previews the same anchor', async () => { + const domListeners: Record void)[]> = {}; + const sql = 'SELECT org_id, title FROM a_cninfo_announcement\n\n'; + editorState.domNode = { + style: { cursor: '' }, + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + domListeners[type] ||= []; + domListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + contains: vi.fn(() => false), + getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, width: 800, height: 300 })), + } as any; + editorState.editor.getTargetAtClientPoint = vi.fn(() => ({ + type: 7, + position: { lineNumber: 3, column: 1 }, + })); + editorState.editor.getVisibleRanges = vi.fn(() => [{ startLineNumber: 1, endLineNumber: 3 }]); + editorState.editor.getScrolledVisiblePosition = vi.fn(({ lineNumber, column }: any) => ({ + left: (column - 1) * 10, + top: (lineNumber - 1) * 20, + height: 20, + })); + editorState.editor.render = vi.fn(); + editorState.value = sql; + + await act(async () => { + create(); + }); + + const titleOffset = sql.indexOf('title'); + const createDataTransfer = () => ({ + types: [ + 'application/x-gonavi-sql-object', + 'application/x-gonavi-sql-field', + 'text/plain', + ], + dropEffect: 'none', + getData: (type: string) => { + if (type === 'application/x-gonavi-sql-object') { + return JSON.stringify({ text: 'announcement_id', nodeType: 'column' }); + } + return 'announcement_id'; + }, + }); + const dragCoordinates = { + clientX: (titleOffset + 2) * 10, + clientY: 100, + }; + + await act(async () => { + domListeners.dragover?.forEach((listener) => listener({ + ...dragCoordinates, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: createDataTransfer(), + })); + }); + + const previewDecoration = editorState.editor.deltaDecorations.mock.calls + .flatMap((call: any[]) => call[1] || []) + .find((decoration: any) => decoration?.options?.inlineClassName === 'gonavi-query-editor-field-drop-anchor'); + expect(previewDecoration?.range).toMatchObject({ + startLineNumber: 1, + startColumn: titleOffset + 1, + endLineNumber: 1, + endColumn: titleOffset + 'title'.length + 1, + }); + + await act(async () => { + domListeners.drop?.forEach((listener) => listener({ + ...dragCoordinates, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: createDataTransfer(), + })); + }); + + expect(editorState.value).toBe( + 'SELECT org_id, title, announcement_id FROM a_cninfo_announcement\n\n', + ); + }); + it('fetches database and completion metadata only for the active query tab', async () => { autoFetchState.visible = true; backendApp.DBGetDatabases.mockResolvedValue({ diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ef822b77..3f850c4a 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -50,6 +50,12 @@ import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; +import { + buildSqlFieldDropEdit, + hasSqlFieldDragPayload, + resolveSqlFieldDropAnchorRange, + resolveSqlFieldDropCursorOffset, +} from '../utils/sqlFieldDrop'; import { CLOSE_ACTIVE_RESULT_TAB_EVENT, type CloseActiveResultShortcutRequest, @@ -1548,6 +1554,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const linkDecorationIdsRef = useRef([]); const ctrlMetaPressedRef = useRef(false); const objectDecorationIdsRef = useRef([]); + const sqlFieldDropDecorationIdsRef = useRef([]); const aiInlineGhostDecorationIdsRef = useRef([]); const aiInlineGhostOverlayRef = useRef(null); const aiInlineGhostVisibleContextKeyRef = useRef(null); @@ -2995,6 +3002,113 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return true; }, []); + const resolveSqlFieldDropPosition = useCallback((editor: any, event: DragEvent) => { + const model = editor?.getModel?.(); + if (!editor || !model) return null; + + const monacoTarget = editor.getTargetAtClientPoint?.(event.clientX, event.clientY); + // CONTENT_EMPTY 会把文字下方的鼠标位置钳制到行尾,必须保留横坐标重新投影。 + let position = Number(monacoTarget?.type) === 6 + ? normalizeEditorPosition(monacoTarget?.position) + : null; + if (!position) { + const editorDomNode = editor.getDomNode?.() as HTMLElement | null; + const bounds = editorDomNode?.getBoundingClientRect?.(); + const visibleRanges = editor.getVisibleRanges?.() || []; + if (bounds && visibleRanges.length > 0) { + const localX = event.clientX - bounds.left; + const localY = event.clientY - bounds.top; + const visibleLines: number[] = []; + visibleRanges.forEach((range: any) => { + const startLine = Math.max(1, Number(range?.startLineNumber || 1)); + const endLine = Math.max(startLine, Number(range?.endLineNumber || startLine)); + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) { + if (!visibleLines.includes(lineNumber)) visibleLines.push(lineNumber); + } + }); + const nonEmptyLines = visibleLines.filter((lineNumber) => ( + String(model.getLineContent?.(lineNumber) || '').trim().length > 0 + )); + const candidateLines = nonEmptyLines.length > 0 ? nonEmptyLines : visibleLines; + let nearestLine = Number(candidateLines[0] || 1); + let nearestLineDistance = Number.POSITIVE_INFINITY; + candidateLines.forEach((lineNumber) => { + const visible = editor.getScrolledVisiblePosition?.({ lineNumber, column: 1 }); + if (!visible) return; + const distance = Math.abs(localY - (visible.top + visible.height / 2)); + if (distance < nearestLineDistance) { + nearestLine = lineNumber; + nearestLineDistance = distance; + } + }); + + const maxColumn = Math.max(1, Number(model.getLineMaxColumn?.(nearestLine) || 1)); + let low = 1; + let high = maxColumn; + while (low < high) { + const middle = Math.floor((low + high) / 2); + const visible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column: middle }); + if (!visible || visible.left < localX) low = middle + 1; + else high = middle; + } + const candidateColumns = [Math.max(1, low - 1), low, Math.min(maxColumn, low + 1)]; + const nearestColumn = candidateColumns.reduce((best, column) => { + const bestVisible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column: best }); + const candidateVisible = editor.getScrolledVisiblePosition?.({ lineNumber: nearestLine, column }); + if (!candidateVisible) return best; + if (!bestVisible) return column; + return Math.abs(candidateVisible.left - localX) < Math.abs(bestVisible.left - localX) + ? column + : best; + }, candidateColumns[0]); + position = normalizeEditorPosition({ lineNumber: nearestLine, column: nearestColumn }); + } + } + position = position + || normalizeEditorPosition(editor.getPosition?.()) + || normalizeEditorPosition(lastEditorCursorPositionRef.current); + if (!position) return null; + + const rawOffset = Number(model.getOffsetAt?.(position)); + if (!Number.isFinite(rawOffset) || typeof model.getPositionAt !== 'function') return position; + return normalizeEditorPosition(model.getPositionAt( + resolveSqlFieldDropCursorOffset(String(model.getValue?.() || ''), rawOffset), + )) || position; + }, []); + + const clearSqlFieldDropPreview = useCallback((editor: any) => { + if (!editor?.deltaDecorations) { + sqlFieldDropDecorationIdsRef.current = []; + return; + } + sqlFieldDropDecorationIdsRef.current = editor.deltaDecorations( + sqlFieldDropDecorationIdsRef.current, + [], + ); + }, []); + + const updateSqlFieldDropPreview = useCallback((editor: any, position: any) => { + const model = editor?.getModel?.(); + const monaco = monacoRef.current; + const offset = Number(model?.getOffsetAt?.(position)); + const anchor = model && Number.isFinite(offset) + ? resolveSqlFieldDropAnchorRange(String(model.getValue?.() || ''), offset) + : null; + if (!anchor || !monaco?.Range || typeof model?.getPositionAt !== 'function') { + clearSqlFieldDropPreview(editor); + return; + } + const start = model.getPositionAt(anchor.startOffset); + const end = model.getPositionAt(anchor.endOffset); + sqlFieldDropDecorationIdsRef.current = editor.deltaDecorations( + sqlFieldDropDecorationIdsRef.current, + [{ + range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), + options: { inlineClassName: 'gonavi-query-editor-field-drop-anchor' }, + }], + ); + }, [clearSqlFieldDropPreview]); + const mergeSidebarDropObjectMetadata = useCallback((payload: ReturnType) => { if (!payload?.text || !payload.dbName) { return; @@ -3042,12 +3156,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } const editor = editorRef.current; - const dropTarget = editor?.getTargetAtClientPoint?.(event.clientX, event.clientY); - if (insertTextIntoEditorAtPosition(dragText, normalizeEditorPosition(dropTarget?.position))) { + clearSqlFieldDropPreview(editor); + const payloadNodeType = String(payload?.nodeType || '').trim().toLowerCase(); + const targetPosition = payloadNodeType === 'column' + ? resolveSqlFieldDropPosition(editor, event) + : normalizeEditorPosition(editor?.getTargetAtClientPoint?.(event.clientX, event.clientY)?.position) + || normalizeEditorPosition(editor?.getPosition?.()) + || normalizeEditorPosition(lastEditorCursorPositionRef.current); + let inserted = false; + if (payloadNodeType === 'column' && editor && targetPosition) { + const model = editor.getModel?.(); + const monaco = monacoRef.current; + const offset = Number(model?.getOffsetAt?.(targetPosition)); + const edit = model && monaco?.Range && typeof model.getPositionAt === 'function' && Number.isFinite(offset) + ? buildSqlFieldDropEdit({ sql: String(model?.getValue?.() || ''), offset, fieldName: dragText }) + : null; + if (edit) { + const start = model.getPositionAt(edit.startOffset); + const end = model.getPositionAt(edit.endOffset); + editor.focus?.(); + editor.setPosition?.(targetPosition); + editor.executeEdits?.('gonavi-result-field-drop', [{ + range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), + text: edit.text, + forceMoveMarkers: true, + }]); + editor.pushUndoStop?.(); + inserted = true; + } + } else { + inserted = insertTextIntoEditorAtPosition(dragText, targetPosition); + } + if (inserted) { mergeSidebarDropObjectMetadata(payload); refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); } - }, [insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations]); + }, [clearSqlFieldDropPreview, insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations, resolveSqlFieldDropPosition]); const handleSelectCurrentStatement = async () => { const editor = editorRef.current; @@ -5109,6 +5253,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } + if (hasSqlFieldDragPayload(event.dataTransfer)) { + const dropPosition = resolveSqlFieldDropPosition(editor, event); + if (dropPosition) { + editor.setPosition?.(dropPosition); + lastEditorCursorPositionRef.current = dropPosition; + updateSqlFieldDropPreview(editor, dropPosition); + editor.render?.(false); + } else { + clearSqlFieldDropPreview(editor); + } + } + }; + const handleEditorDragLeave = (rawEvent: Event) => { + const relatedTarget = (rawEvent as DragEvent).relatedTarget as Node | null; + if (relatedTarget && editorDomNode?.contains?.(relatedTarget)) return; + clearSqlFieldDropPreview(editor); + }; + const handleSqlFieldDragEnd = () => { + clearSqlFieldDropPreview(editor); }; const handleEditorDrop = (rawEvent: Event) => { handleSidebarObjectDrop(rawEvent as DragEvent); @@ -5348,10 +5511,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc window.addEventListener('keydown', syncModifierState); window.addEventListener('keyup', syncModifierState); window.addEventListener('blur', handleWindowBlur); + window.addEventListener('dragend', handleSqlFieldDragEnd); + window.addEventListener('drop', handleSqlFieldDragEnd); editorDomNode?.addEventListener('beforeinput', handleImeBeforeInput, true); editorDomNode?.addEventListener('compositionstart', handleImeCompositionStart, true); editorDomNode?.addEventListener('compositionend', handleImeCompositionEnd, true); editorDomNode?.addEventListener('dragover', handleEditorDragOver, true); + editorDomNode?.addEventListener('dragleave', handleEditorDragLeave, true); editorDomNode?.addEventListener('drop', handleEditorDrop, true); editor.onMouseDown?.((event: any) => { @@ -5523,6 +5689,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc editor.onDidDispose?.(() => { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef); + clearSqlFieldDropPreview(editor); setQueryEditorMouseCursor(editor, ''); objectHoverActionRef.current?.dispose?.(); objectHoverActionRef.current = null; @@ -5545,11 +5712,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc window.removeEventListener('keydown', syncModifierState); window.removeEventListener('keyup', syncModifierState); window.removeEventListener('blur', handleWindowBlur); + window.removeEventListener('dragend', handleSqlFieldDragEnd); + window.removeEventListener('drop', handleSqlFieldDragEnd); clearImeCompositionFallbackTimer(); editorDomNode?.removeEventListener('beforeinput', handleImeBeforeInput, true); editorDomNode?.removeEventListener('compositionstart', handleImeCompositionStart, true); editorDomNode?.removeEventListener('compositionend', handleImeCompositionEnd, true); editorDomNode?.removeEventListener('dragover', handleEditorDragOver, true); + editorDomNode?.removeEventListener('dragleave', handleEditorDragLeave, true); editorDomNode?.removeEventListener('drop', handleEditorDrop, true); }); diff --git a/frontend/src/utils/sqlFieldDrop.test.ts b/frontend/src/utils/sqlFieldDrop.test.ts new file mode 100644 index 00000000..34d00284 --- /dev/null +++ b/frontend/src/utils/sqlFieldDrop.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSqlFieldDropEdit, + resolveSqlFieldDropAnchorRange, + resolveSqlFieldDropCursorOffset, +} from './sqlFieldDrop'; + +const applyEdit = (sql: string, offset: number, fieldName: string): string => { + const edit = buildSqlFieldDropEdit({ sql, offset, fieldName }); + if (!edit) return sql; + return `${sql.slice(0, edit.startOffset)}${edit.text}${sql.slice(edit.endOffset)}`; +}; + +describe('buildSqlFieldDropEdit', () => { + it('replaces a select star', () => { + const sql = 'select * from users'; + expect(applyEdit(sql, sql.indexOf('*'), 'name')).toBe('select name from users'); + }); + + it('inserts directly after select', () => { + const sql = 'select from users'; + expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select name from users'); + }); + + it('adds a comma after an existing select field', () => { + const sql = 'select id from users'; + expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select id, name from users'); + }); + + it('does not duplicate a comma', () => { + const sql = 'select id, from users'; + expect(applyEdit(sql, sql.indexOf('from'), 'name')).toBe('select id, name from users'); + }); + + it('adds a comma when dropped immediately after an existing field', () => { + const sql = 'select id from users'; + expect(applyEdit(sql, sql.indexOf(' from'), 'name')).toBe('select id, name from users'); + }); + + it('supports insert column lists and update set lists', () => { + const insertSql = 'insert into users () values ()'; + expect(applyEdit(insertSql, insertSql.indexOf('(') + 1, 'name')).toBe('insert into users (name) values ()'); + expect(applyEdit('update users set where id = 1', 17, 'name')).toBe('update users set name where id = 1'); + const updateSql = 'update users set id = 1 where name = ?'; + expect(applyEdit(updateSql, updateSql.indexOf(' where'), 'enabled')).toBe('update users set id = 1, enabled where name = ?'); + + const populatedInsertSql = 'insert into users (id, name) values (?, ?)'; + expect(applyEdit(populatedInsertSql, populatedInsertSql.indexOf('id') + 1, 'enabled')) + .toBe('insert into users (id, enabled, name) values (?, ?)'); + expect(applyEdit(populatedInsertSql, populatedInsertSql.indexOf('name') + 1, 'name')) + .toBe(populatedInsertSql); + }); + + it('does not add a comma in predicate contexts', () => { + const sql = 'delete from users where = 1'; + expect(applyEdit(sql, sql.indexOf('=') - 1, 'id')).toBe('delete from users where id = 1'); + }); + + it('snaps a drop inside an identifier to the complete field boundary', () => { + const sql = 'SELECT announcement_id FROM announcements'; + const rawOffset = sql.indexOf('announcement_id') + 2; + expect(resolveSqlFieldDropCursorOffset(sql, rawOffset)).toBe(sql.indexOf('announcement_id') + 'announcement_id'.length); + expect(applyEdit(sql, rawOffset, 'org_id')).toBe('SELECT announcement_id, org_id FROM announcements'); + }); + + it('adds the trailing comma when inserting before the first field', () => { + const sql = 'SELECT announcement_id, created_at FROM announcements'; + expect(applyEdit(sql, sql.indexOf('announcement_id'), 'org_id')) + .toBe('SELECT org_id, announcement_id, created_at FROM announcements'); + }); + + it('rejects duplicate fields including aliases and qualified references', () => { + const sql = 'SELECT a.announcement_id an, org_id org_i FROM announcements a'; + expect(applyEdit(sql, sql.indexOf('org_id'), 'announcement_id')).toBe(sql); + expect(applyEdit(sql, sql.indexOf(' FROM'), 'org_id')).toBe(sql); + expect(applyEdit(sql, sql.indexOf(' FROM'), 'an')).toBe(sql); + }); + + it('treats function calls with commas as one projection item', () => { + const sql = 'SELECT COALESCE(title, short_title) display_title, created_at FROM announcements'; + expect(applyEdit(sql, sql.indexOf('short_title') + 3, 'org_id')) + .toBe('SELECT COALESCE(title, short_title) display_title, org_id, created_at FROM announcements'); + }); + + it('preserves select modifiers when replacing a star', () => { + const sql = 'SELECT DISTINCT * FROM announcements'; + expect(applyEdit(sql, sql.indexOf('*'), 'announcement_id')) + .toBe('SELECT DISTINCT announcement_id FROM announcements'); + }); + + it('only treats direct fields and output aliases as duplicates', () => { + const sql = 'SELECT COUNT(announcement_id) announcement_count FROM announcements'; + expect(applyEdit(sql, sql.indexOf(' FROM'), 'announcement_id')) + .toBe('SELECT COUNT(announcement_id) announcement_count, announcement_id FROM announcements'); + expect(applyEdit(sql, sql.indexOf(' FROM'), 'announcement_count')).toBe(sql); + }); + + it('keeps dialect-specific select modifiers ahead of inserted fields', () => { + const topSql = 'SELECT TOP 10 announcement_id FROM announcements'; + expect(applyEdit(topSql, topSql.indexOf('announcement_id'), 'org_id')) + .toBe('SELECT TOP 10 org_id, announcement_id FROM announcements'); + + const distinctOnSql = 'SELECT DISTINCT ON (org_id) announcement_id FROM announcements'; + expect(applyEdit(distinctOnSql, distinctOnSql.indexOf('announcement_id'), 'created_at')) + .toBe('SELECT DISTINCT ON (org_id) created_at, announcement_id FROM announcements'); + + const mysqlSql = 'SELECT SQL_CALC_FOUND_ROWS announcement_id FROM announcements'; + expect(applyEdit(mysqlSql, mysqlSql.indexOf('announcement_id'), 'org_id')) + .toBe('SELECT SQL_CALC_FOUND_ROWS org_id, announcement_id FROM announcements'); + }); + + it('anchors and inserts after the field below the horizontal drag position', () => { + const sql = 'SELECT org_id, title FROM a_cninfo_announcement'; + const orgOffset = sql.indexOf('org_id') + 2; + const titleOffset = sql.indexOf('title') + 2; + + expect(resolveSqlFieldDropAnchorRange(sql, resolveSqlFieldDropCursorOffset(sql, orgOffset))).toEqual({ + startOffset: sql.indexOf('org_id'), + endOffset: sql.indexOf('org_id') + 'org_id'.length, + }); + expect(applyEdit(sql, resolveSqlFieldDropCursorOffset(sql, orgOffset), 'announcement_id')) + .toBe('SELECT org_id, announcement_id, title FROM a_cninfo_announcement'); + + expect(resolveSqlFieldDropAnchorRange(sql, resolveSqlFieldDropCursorOffset(sql, titleOffset))).toEqual({ + startOffset: sql.indexOf('title'), + endOffset: sql.indexOf('title') + 'title'.length, + }); + expect(applyEdit(sql, resolveSqlFieldDropCursorOffset(sql, titleOffset), 'announcement_id')) + .toBe('SELECT org_id, title, announcement_id FROM a_cninfo_announcement'); + }); + + it('uses the nearest field on either side of projection whitespace', () => { + const sql = 'SELECT org_id, title FROM a_cninfo_announcement'; + const gapStart = sql.indexOf('org_id') + 'org_id'.length; + const nearOrg = gapStart + 1; + const nearTitle = sql.indexOf('title') - 1; + + expect(resolveSqlFieldDropAnchorRange(sql, nearOrg)).toEqual({ + startOffset: sql.indexOf('org_id'), + endOffset: gapStart, + }); + expect(resolveSqlFieldDropAnchorRange(sql, nearTitle)).toEqual({ + startOffset: sql.indexOf('title'), + endOffset: sql.indexOf('title') + 'title'.length, + }); + expect(applyEdit(sql, nearTitle, 'announcement_id')) + .toBe('SELECT org_id, title, announcement_id FROM a_cninfo_announcement'); + }); +}); diff --git a/frontend/src/utils/sqlFieldDrop.ts b/frontend/src/utils/sqlFieldDrop.ts new file mode 100644 index 00000000..cd4ea229 --- /dev/null +++ b/frontend/src/utils/sqlFieldDrop.ts @@ -0,0 +1,474 @@ +export interface SqlFieldDropEditInput { + sql: string; + offset: number; + fieldName: string; +} + +export interface SqlFieldDropEdit { + startOffset: number; + endOffset: number; + text: string; +} + +export interface SqlFieldDropAnchorRange { + startOffset: number; + endOffset: number; +} + +export const SQL_FIELD_DRAG_MIME = 'application/x-gonavi-sql-field'; + +export const hasSqlFieldDragPayload = ( + dataTransfer: Pick | null | undefined, +): boolean => Array.from(dataTransfer?.types || []) + .some((type) => String(type || '').toLowerCase() === SQL_FIELD_DRAG_MIME); + +type SqlToken = { + kind: 'word' | 'identifier' | 'string' | 'symbol'; + value: string; + start: number; + end: number; + depth: number; +}; + +type SqlProjection = { + selectToken: SqlToken; + contentStart: number; + contentEnd: number; + items: Array<{ start: number; end: number; text: string }>; + commaOffsets: number[]; +}; + +const isWordStart = (char: string): boolean => !!char && (/[A-Za-z_@$#]/.test(char) || char.charCodeAt(0) > 127); +const isWordPart = (char: string): boolean => !!char && (/[A-Za-z0-9_@$#]/.test(char) || char.charCodeAt(0) > 127); + +const tokenizeSql = (sql: string): SqlToken[] => { + const tokens: SqlToken[] = []; + let index = 0; + let depth = 0; + + while (index < sql.length) { + const char = sql[index]; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (char === '-' && sql[index + 1] === '-') { + const lineEnd = sql.indexOf('\n', index + 2); + index = lineEnd < 0 ? sql.length : lineEnd + 1; + continue; + } + if (char === '/' && sql[index + 1] === '*') { + const commentEnd = sql.indexOf('*/', index + 2); + index = commentEnd < 0 ? sql.length : commentEnd + 2; + continue; + } + if (char === "'") { + const start = index; + index += 1; + while (index < sql.length) { + if (sql[index] === "'" && sql[index + 1] === "'") { + index += 2; + continue; + } + if (sql[index] === "'") { + index += 1; + break; + } + index += 1; + } + tokens.push({ kind: 'string', value: sql.slice(start, index), start, end: index, depth }); + continue; + } + if (char === '"' || char === '`' || char === '[') { + const start = index; + const closing = char === '[' ? ']' : char; + index += 1; + while (index < sql.length) { + if (sql[index] === closing && sql[index + 1] === closing) { + index += 2; + continue; + } + if (sql[index] === closing) { + index += 1; + break; + } + index += 1; + } + tokens.push({ + kind: 'identifier', + value: sql.slice(start + 1, Math.max(start + 1, index - 1)), + start, + end: index, + depth, + }); + continue; + } + if (isWordStart(char)) { + const start = index; + index += 1; + while (index < sql.length && isWordPart(sql[index])) index += 1; + tokens.push({ kind: 'word', value: sql.slice(start, index), start, end: index, depth }); + continue; + } + if (char === '(') { + tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth }); + depth += 1; + index += 1; + continue; + } + if (char === ')') { + depth = Math.max(0, depth - 1); + tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth }); + index += 1; + continue; + } + tokens.push({ kind: 'symbol', value: char, start: index, end: index + 1, depth }); + index += 1; + } + return tokens; +}; + +const trimRange = (sql: string, start: number, end: number): { start: number; end: number; text: string } | null => { + let nextStart = start; + let nextEnd = end; + while (nextStart < nextEnd && /\s/.test(sql[nextStart])) nextStart += 1; + while (nextEnd > nextStart && /\s/.test(sql[nextEnd - 1])) nextEnd -= 1; + return nextStart < nextEnd + ? { start: nextStart, end: nextEnd, text: sql.slice(nextStart, nextEnd) } + : null; +}; + +const findStatementBounds = (tokens: SqlToken[], offset: number, sqlLength: number): { start: number; end: number } => { + let start = 0; + let end = sqlLength; + tokens.forEach((token) => { + if (token.kind !== 'symbol' || token.value !== ';' || token.depth !== 0) return; + if (token.end <= offset) start = token.end; + else if (token.start >= offset && end === sqlLength) end = token.start; + }); + return { start, end }; +}; + +const findSelectProjection = (sql: string, offset: number, tokens: SqlToken[]): SqlProjection | null => { + const statement = findStatementBounds(tokens, offset, sql.length); + const selectTokens = tokens.filter((token) => ( + token.kind === 'word' + && token.value.toLowerCase() === 'select' + && token.start >= statement.start + && token.end <= offset + )); + + for (let selectIndex = selectTokens.length - 1; selectIndex >= 0; selectIndex -= 1) { + const selectToken = selectTokens[selectIndex]; + const fromToken = tokens.find((token) => ( + token.kind === 'word' + && token.value.toLowerCase() === 'from' + && token.depth === selectToken.depth + && token.start >= selectToken.end + && token.start < statement.end + )); + const contentEnd = fromToken?.start ?? statement.end; + if (offset < selectToken.end || offset > contentEnd) continue; + + let contentStart = selectToken.end; + const modifierMatch = sql.slice(contentStart, contentEnd).match( + /^\s*(?:(?:distinct\s+on\s*\([^)]*\)|distinct\b|all\b)\s*)?(?:top\s*(?:\([^)]*\)|\d+(?:\.\d+)?)\s*(?:percent\s*)?(?:with\s+ties\s*)?)?(?:(?:high_priority|straight_join|sql_small_result|sql_big_result|sql_buffer_result|sql_no_cache|sql_calc_found_rows)\b\s*)*/i, + ); + if (modifierMatch?.[0] && /\S/.test(modifierMatch[0])) contentStart += modifierMatch[0].length; + + const commaOffsets = tokens + .filter((token) => token.kind === 'symbol' + && token.value === ',' + && token.depth === selectToken.depth + && token.start >= contentStart + && token.start < contentEnd) + .map((token) => token.start); + const boundaries = [contentStart, ...commaOffsets.map((comma) => comma + 1), contentEnd]; + const segmentEnds = [...commaOffsets, contentEnd]; + const items = boundaries + .slice(0, segmentEnds.length) + .map((start, index) => trimRange(sql, start, segmentEnds[index])) + .filter((item): item is NonNullable => !!item); + return { selectToken, contentStart, contentEnd, items, commaOffsets }; + } + return null; +}; + +const findInsertColumnList = (sql: string, offset: number, tokens: SqlToken[]): SqlProjection | null => { + const statement = findStatementBounds(tokens, offset, sql.length); + const insertToken = tokens.find((token) => token.kind === 'word' + && token.value.toLowerCase() === 'insert' + && token.start >= statement.start + && token.end <= offset); + if (!insertToken) return null; + const intoToken = tokens.find((token) => token.kind === 'word' + && token.value.toLowerCase() === 'into' + && token.depth === insertToken.depth + && token.start >= insertToken.end + && token.end <= offset); + if (!intoToken) return null; + const valuesToken = tokens.find((token) => token.kind === 'word' + && token.value.toLowerCase() === 'values' + && token.depth === insertToken.depth + && token.start >= intoToken.end + && token.start < statement.end); + const openToken = tokens.find((token) => token.kind === 'symbol' + && token.value === '(' + && token.depth === insertToken.depth + && token.start >= intoToken.end + && token.start < (valuesToken?.start ?? statement.end)); + if (!openToken) return null; + const closeToken = tokens.find((token) => token.kind === 'symbol' + && token.value === ')' + && token.depth === openToken.depth + && token.start >= openToken.end + && token.start < (valuesToken?.start ?? statement.end)); + if (!closeToken || offset < openToken.end || offset > closeToken.start) return null; + + const contentStart = openToken.end; + const contentEnd = closeToken.start; + const commaOffsets = tokens + .filter((token) => token.kind === 'symbol' + && token.value === ',' + && token.depth === openToken.depth + 1 + && token.start >= contentStart + && token.start < contentEnd) + .map((token) => token.start); + const boundaries = [contentStart, ...commaOffsets.map((comma) => comma + 1), contentEnd]; + const segmentEnds = [...commaOffsets, contentEnd]; + const items = boundaries + .slice(0, segmentEnds.length) + .map((start, index) => trimRange(sql, start, segmentEnds[index])) + .filter((item): item is NonNullable => !!item); + return { selectToken: insertToken, contentStart, contentEnd, items, commaOffsets }; +}; + +const normalizeIdentifier = (value: string): string => { + const text = String(value || '').trim(); + const unquoted = (text.startsWith('`') && text.endsWith('`')) + || (text.startsWith('"') && text.endsWith('"')) + || (text.startsWith('[') && text.endsWith(']')) + ? text.slice(1, -1) + : text; + const parts = unquoted.split('.').map((part) => part.trim()).filter(Boolean); + return String(parts[parts.length - 1] || '').toLowerCase(); +}; + +const projectionContainsField = (projection: SqlProjection, fieldName: string): boolean => { + const target = normalizeIdentifier(fieldName); + if (!target) return false; + return projection.items.some((item) => { + const tokens = tokenizeSql(item.text); + const identifierTokens = tokens.filter((token) => token.kind === 'word' || token.kind === 'identifier'); + const topLevelIdentifiers = identifierTokens.filter((token) => token.depth === 0); + const asIndex = topLevelIdentifiers.findIndex((token) => token.value.toLowerCase() === 'as'); + const explicitAlias = asIndex >= 0 ? topLevelIdentifiers[asIndex + 1] : undefined; + if (explicitAlias && normalizeIdentifier(explicitAlias.value) === target) return true; + + const hasTopLevelExpressionSymbol = tokens.some((token) => token.kind === 'symbol' + && token.depth === 0 + && token.value !== '.'); + if (hasTopLevelExpressionSymbol) { + const lastToken = tokens[tokens.length - 1]; + return !!lastToken + && (lastToken.kind === 'word' || lastToken.kind === 'identifier') + && lastToken !== topLevelIdentifiers[0] + && normalizeIdentifier(lastToken.value) === target; + } + + const dotCount = tokens.filter((token) => token.kind === 'symbol' && token.depth === 0 && token.value === '.').length; + const nonAsIdentifiers = topLevelIdentifiers.filter((token) => token.value.toLowerCase() !== 'as'); + const sourceIdentifierCount = Math.min(nonAsIdentifiers.length, dotCount + 1); + const sourceIdentifier = nonAsIdentifiers[sourceIdentifierCount - 1]; + const implicitAlias = nonAsIdentifiers[sourceIdentifierCount]; + return normalizeIdentifier(sourceIdentifier?.value || '') === target + || normalizeIdentifier(implicitAlias?.value || '') === target; + }); +}; + +type SqlProjectionDropPlacement = { + item: SqlProjection['items'][number]; + position: 'before' | 'after'; +}; + +const resolveProjectionDropPlacement = ( + projection: SqlProjection, + rawOffset: number, +): SqlProjectionDropPlacement | null => { + const firstItem = projection.items[0]; + if (!firstItem) return null; + if (rawOffset <= firstItem.start) { + return { item: firstItem, position: 'before' }; + } + + for (let index = 0; index < projection.items.length; index += 1) { + const item = projection.items[index]; + const nextItem = projection.items[index + 1]; + if (rawOffset <= item.end) { + return { item, position: 'after' }; + } + if (!nextItem) { + return { item, position: 'after' }; + } + if (rawOffset < nextItem.start) { + const distanceFromPrevious = Math.abs(rawOffset - item.end); + const distanceToNext = Math.abs(nextItem.start - rawOffset); + return distanceFromPrevious <= distanceToNext + ? { item, position: 'after' } + : { item: nextItem, position: 'after' }; + } + } + return null; +}; + +/** 将落在完整标识符内部的位置吸附到标识符末尾,避免拆词。 */ +export const resolveSqlFieldDropCursorOffset = (sql: string, offset: number): number => { + const source = String(sql || ''); + const cursor = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length)); + const tokens = tokenizeSql(source); + const projection = findSelectProjection(source, cursor, tokens) + || findInsertColumnList(source, cursor, tokens); + const projectionItem = projection?.items.find((item) => cursor > item.start && cursor < item.end); + if (projectionItem) return projectionItem.end; + const token = tokens.find((candidate) => ( + (candidate.kind === 'word' || candidate.kind === 'identifier') + && cursor > candidate.start + && cursor < candidate.end + )); + return token?.end ?? cursor; +}; + +/** 返回拖拽释放后作为插入基准的完整字段或表达式范围,用于编辑器预览高亮。 */ +export const resolveSqlFieldDropAnchorRange = ( + sql: string, + offset: number, +): SqlFieldDropAnchorRange | null => { + const source = String(sql || ''); + const rawOffset = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length)); + const tokens = tokenizeSql(source); + const projection = findSelectProjection(source, rawOffset, tokens) + || findInsertColumnList(source, rawOffset, tokens); + const placement = projection ? resolveProjectionDropPlacement(projection, rawOffset) : null; + if (!placement || placement.position !== 'after') return null; + return { + startOffset: placement.item.start, + endOffset: placement.item.end, + }; +}; + +const buildProjectionEdit = ( + sql: string, + projection: SqlProjection, + rawOffset: number, + fieldName: string, +): SqlFieldDropEdit | null => { + if (projectionContainsField(projection, fieldName)) return null; + if (projection.items.length === 1 && projection.items[0].text.trim() === '*') { + return { + startOffset: projection.items[0].start, + endOffset: projection.items[0].end, + text: fieldName, + }; + } + if (projection.items.length === 0) { + const isSelectProjection = projection.selectToken.value.toLowerCase() === 'select'; + return { + startOffset: projection.contentStart, + endOffset: projection.contentEnd, + text: isSelectProjection ? ` ${fieldName} ` : fieldName, + }; + } + + const trailingComma = projection.commaOffsets.find((comma) => comma >= projection.items[projection.items.length - 1].end); + if (trailingComma !== undefined && rawOffset >= trailingComma) { + return { + startOffset: trailingComma + 1, + endOffset: projection.contentEnd, + text: ` ${fieldName} `, + }; + } + + const placement = resolveProjectionDropPlacement(projection, rawOffset); + if (!placement) return null; + if (placement.position === 'before') { + return { startOffset: placement.item.start, endOffset: placement.item.start, text: `${fieldName}, ` }; + } + const isLastItem = placement.item === projection.items[projection.items.length - 1]; + return isLastItem + ? { + startOffset: placement.item.end, + endOffset: projection.contentEnd, + text: `, ${fieldName} `, + } + : { + startOffset: placement.item.end, + endOffset: placement.item.end, + text: `, ${fieldName}`, + }; +}; + +const buildUpdateSetEdit = ( + sql: string, + tokens: SqlToken[], + rawOffset: number, + fieldName: string, +): SqlFieldDropEdit | null => { + const statement = findStatementBounds(tokens, rawOffset, sql.length); + const updateToken = tokens.find((token) => token.kind === 'word' + && token.value.toLowerCase() === 'update' + && token.start >= statement.start + && token.end <= rawOffset); + if (!updateToken) return null; + const setToken = tokens.find((token) => token.kind === 'word' + && token.value.toLowerCase() === 'set' + && token.depth === updateToken.depth + && token.start >= updateToken.end + && token.end <= rawOffset); + if (!setToken) return null; + const clauseEndToken = tokens.find((token) => token.kind === 'word' + && ['where', 'returning', 'order', 'limit'].includes(token.value.toLowerCase()) + && token.depth === updateToken.depth + && token.start >= setToken.end); + const clauseEnd = clauseEndToken?.start ?? statement.end; + if (rawOffset > clauseEnd) return null; + const existingText = sql.slice(setToken.end, clauseEnd); + const existingIdentifiers = tokenizeSql(existingText) + .filter((token) => token.kind === 'word' || token.kind === 'identifier') + .map((token) => normalizeIdentifier(token.value)); + if (existingIdentifiers.includes(normalizeIdentifier(fieldName))) return null; + const trimmed = trimRange(sql, setToken.end, clauseEnd); + if (!trimmed) { + return { startOffset: setToken.end, endOffset: clauseEnd, text: ` ${fieldName} ` }; + } + return { startOffset: trimmed.end, endOffset: clauseEnd, text: `, ${fieldName} ` }; +}; + +/** + * 计算结果集字段拖入 SQL 编辑器时的最小编辑范围。 + * SELECT 字段列表按完整表达式插入,并拒绝已有字段;其它位置只做安全标记边界插入。 + */ +export const buildSqlFieldDropEdit = ({ sql, offset, fieldName }: SqlFieldDropEditInput): SqlFieldDropEdit | null => { + const source = String(sql || ''); + const field = String(fieldName || '').trim(); + const rawOffset = Math.max(0, Math.min(Number.isFinite(offset) ? offset : 0, source.length)); + if (!field) return null; + + const tokens = tokenizeSql(source); + const projection = findSelectProjection(source, rawOffset, tokens); + if (projection) return buildProjectionEdit(source, projection, rawOffset, field); + const insertColumnList = findInsertColumnList(source, rawOffset, tokens); + if (insertColumnList) return buildProjectionEdit(source, insertColumnList, rawOffset, field); + const updateSetEdit = buildUpdateSetEdit(source, tokens, rawOffset, field); + if (updateSetEdit) return updateSetEdit; + + const cursor = resolveSqlFieldDropCursorOffset(source, rawOffset); + const before = source.slice(0, cursor); + const after = source.slice(cursor); + const needsLeadingSpace = !!before && !/[\s(,]$/.test(before); + const needsTrailingSpace = !!after && !/^[\s),;]/.test(after); + return { + startOffset: cursor, + endOffset: cursor, + text: `${needsLeadingSpace ? ' ' : ''}${field}${needsTrailingSpace ? ' ' : ''}`, + }; +};