mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-18 04:44:18 +08:00
✨ feat(query): 支持拖拽结果字段插入 SQL (#848)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<SortableHeaderCellProps> = React.memo((props)
|
||||
}}
|
||||
>
|
||||
<style>{sortableHeaderStaticStyles}</style>
|
||||
<div className="sortable-header-cell-drag-handle" title={t('data_grid.column.drag_tooltip')}>
|
||||
<div
|
||||
className="sortable-header-cell-drag-handle"
|
||||
title={t('data_grid.column.drag_tooltip')}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0, cursor: 'inherit' }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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<string, ((event?: any) => 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(<QueryEditor tab={createTab({ query: sql })} />);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const ctrlMetaPressedRef = useRef(false);
|
||||
const objectDecorationIdsRef = useRef<string[]>([]);
|
||||
const sqlFieldDropDecorationIdsRef = useRef<string[]>([]);
|
||||
const aiInlineGhostDecorationIdsRef = useRef<string[]>([]);
|
||||
const aiInlineGhostOverlayRef = useRef<HTMLSpanElement | null>(null);
|
||||
const aiInlineGhostVisibleContextKeyRef = useRef<any>(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<typeof decodeSidebarSqlEditorDragPayload>) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
149
frontend/src/utils/sqlFieldDrop.test.ts
Normal file
149
frontend/src/utils/sqlFieldDrop.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
474
frontend/src/utils/sqlFieldDrop.ts
Normal file
474
frontend/src/utils/sqlFieldDrop.ts
Normal file
@@ -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<DataTransfer, 'types'> | 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<typeof item> => !!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<typeof item> => !!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 ? ' ' : ''}`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user