feat(query): 支持拖拽结果字段插入 SQL (#870)

## 变更说明

- 支持将查询结果集字段拖入 SQL 编辑器,并按光标位置、选区和字段上下文生成插入文本
- 复用同一次表头拖拽完成结果集内部列排序,不影响字段拖入查询区
- 使用结果集作用域隔离列排序,避免跨结果集误重排
- 鼠标使用原生 HTML 拖放,触摸和触控笔保留 dnd-kit PointerSensor 排序能力

## 测试

- `npm test -- sqlFieldDrop.test.ts dataGridColumnOrder.test.ts`(19
个测试通过)
- `npm run build`
- Windows Wails 桌面端手动验证:结果集列排序、字段拖入查询区

Closes #848
This commit is contained in:
virgosgod-maker
2026-08-07 21:45:02 +08:00
committed by GitHub
9 changed files with 1086 additions and 38 deletions

View File

@@ -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;
}

View File

@@ -19,10 +19,15 @@ import {
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
arrayMove
horizontalListSortingStrategy
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
decodeDataGridColumnOrderDragPayload,
hasDataGridColumnOrderDragPayload,
moveDataGridColumnInVisibleOrder,
} from './dataGridColumnOrder';
import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
@@ -576,42 +581,29 @@ const DataGrid: React.FC<DataGridProps> = ({
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const handleDragEnd = (event: DragEndEvent) => {
// 防御性检查:若正在调整列宽,忽略拖拽排序事件
if (isResizingRef.current) return;
const { active, over } = event;
if (active.id !== over?.id && over) {
const columnOrderDragScopeRef = useRef(generateUuid());
const reorderVisibleColumns = useCallback((sourceColumnName: string, targetColumnName: string) => {
setAllOrderedColumnNames((prevAllOrder) => {
// Calculate the new order of all columns by applying the movement
// We only move the visible columns relative to each other, but the easiest way
// is to map the visible column movement back to the full array.
const hiddenSet = new Set(localHiddenColumns);
const visibleOrder = prevAllOrder.filter(col => !hiddenSet.has(col));
const oldVisibleIndex = visibleOrder.indexOf(active.id as string);
const newVisibleIndex = visibleOrder.indexOf(over.id as string);
if (oldVisibleIndex === -1 || newVisibleIndex === -1) return prevAllOrder;
const nextVisibleOrder = arrayMove(visibleOrder, oldVisibleIndex, newVisibleIndex);
// Reconstruct allOrderedColumnNames by inserting hidden columns back to their original relative positions
// Or simpler: just keep hidden columns at the end, but that ruins user's layout.
// Better approach: build a new array
let vIndex = 0;
const nextOrder = prevAllOrder.map(col => {
if (hiddenSet.has(col)) {
return col; // Hidden columns stay at their absolute index in the master list
} else {
return nextVisibleOrder[vIndex++];
}
});
const nextOrder = moveDataGridColumnInVisibleOrder(
prevAllOrder,
new Set(localHiddenColumns),
sourceColumnName,
targetColumnName,
);
if (nextOrder === prevAllOrder) return prevAllOrder;
if (enableColumnOrderMemory && connectionId && dbName && tableName) {
setTableColumnOrder(connectionId, dbName, tableName, nextOrder);
}
return nextOrder;
});
}, [connectionId, dbName, enableColumnOrderMemory, localHiddenColumns, setTableColumnOrder, tableName]);
const handleDragEnd = (event: DragEndEvent) => {
// 防御性检查:若正在调整列宽,忽略拖拽排序事件
if (isResizingRef.current) return;
const { active, over } = event;
if (active.id !== over?.id && over) {
reorderVisibleColumns(String(active.id), String(over.id));
}
};
@@ -3106,11 +3098,28 @@ const DataGrid: React.FC<DataGridProps> = ({
},
onHeaderCell: (column: any) => ({
id: key,
columnOrderDragScope: columnOrderDragScopeRef.current,
width: column.width,
className: `gonavi-sortable-header-cell${showColumnComment || showColumnType ? '' : ' is-single-line-title'}`,
'data-i18n-language': language,
onResizeStart: handleResizeStart(key), // Only need start
onResizeAutoFit: handleResizeAutoFit(key),
onDragOver: (event: React.DragEvent<HTMLElement>) => {
if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
},
onDrop: (event: React.DragEvent<HTMLElement>) => {
if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return;
const payload = decodeDataGridColumnOrderDragPayload(
event.dataTransfer.getData(DATA_GRID_COLUMN_ORDER_DRAG_MIME),
);
if (!payload || payload.scope !== columnOrderDragScopeRef.current) return;
event.preventDefault();
event.stopPropagation();
reorderVisibleColumns(payload.columnName, key);
},
onContextMenu: (event: React.MouseEvent<HTMLElement>) => {
if (!isV2Ui) return;
showColumnHeaderContextMenu(event, key);
@@ -3151,7 +3160,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
}, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, reorderVisibleColumns, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);

View File

@@ -75,6 +75,13 @@ 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 {
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
encodeDataGridColumnOrderDragPayload,
shouldBypassDndKitForNativeColumnHeaderDrag,
} from './dataGridColumnOrder';
import {
TEMPORAL_FORMATS,
formatFromDayjs,
@@ -782,6 +789,7 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
// --- Sortable Header Cell ---
interface SortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
id?: string;
columnOrderDragScope?: string;
}
// --- Sortable Header Cell ---
@@ -815,7 +823,7 @@ const sortableHeaderStaticStyles = `
`;
const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props) => {
const { id, children, style: propStyle, className: propClassName, ...restProps } = props;
const { id, children, style: propStyle, className: propClassName, columnOrderDragScope, ...restProps } = props;
const [isPressed, setIsPressed] = useState(false);
const {
attributes,
@@ -873,11 +881,43 @@ const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props)
{...listeners}
onPointerDown={(e: any) => {
setIsPressed(true);
if (
(e.target as HTMLElement | null)?.closest?.('.sortable-header-cell-drag-handle')
&& shouldBypassDndKitForNativeColumnHeaderDrag(e.pointerType)
) {
return;
}
if (listeners?.onPointerDown) listeners.onPointerDown(e);
}}
>
<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 = 'copyMove';
const payload = encodeSidebarSqlEditorDragPayload({
text: columnName,
nodeType: 'column',
});
event.dataTransfer.setData(SIDEBAR_SQL_EDITOR_DRAG_MIME, payload);
event.dataTransfer.setData(SQL_FIELD_DRAG_MIME, columnName);
if (columnOrderDragScope) {
event.dataTransfer.setData(
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
encodeDataGridColumnOrderDragPayload({
scope: columnOrderDragScope,
columnName,
}),
);
}
event.dataTransfer.setData('text/plain', columnName);
}}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0, cursor: 'inherit' }}>
{children}
</div>

View File

@@ -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({

View File

@@ -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);
@@ -3045,6 +3052,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;
@@ -3092,12 +3206,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;
@@ -5150,6 +5294,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);
@@ -5389,10 +5552,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) => {
@@ -5564,6 +5730,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;
@@ -5586,11 +5753,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);
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
DATA_GRID_COLUMN_ORDER_DRAG_MIME,
decodeDataGridColumnOrderDragPayload,
encodeDataGridColumnOrderDragPayload,
hasDataGridColumnOrderDragPayload,
moveDataGridColumnInVisibleOrder,
shouldBypassDndKitForNativeColumnHeaderDrag,
} from './dataGridColumnOrder';
describe('dataGridColumnOrder helpers', () => {
it('reorders a dragged visible column at the header drop target while hidden columns keep their slot', () => {
expect(moveDataGridColumnInVisibleOrder(
['id', 'hidden_note', 'name', 'code'],
new Set(['hidden_note']),
'code',
'id',
)).toEqual(['code', 'hidden_note', 'id', 'name']);
});
it('keeps the column reorder payload scoped to its source result set', () => {
const payload = encodeDataGridColumnOrderDragPayload({
scope: 'result-set-1',
columnName: 'title',
});
expect(decodeDataGridColumnOrderDragPayload(payload)).toEqual({
scope: 'result-set-1',
columnName: 'title',
});
expect(hasDataGridColumnOrderDragPayload({
types: [DATA_GRID_COLUMN_ORDER_DRAG_MIME],
})).toBe(true);
expect(decodeDataGridColumnOrderDragPayload('{')).toBeNull();
});
it('keeps touch and pen pointer input on dnd-kit instead of native HTML drag', () => {
expect(shouldBypassDndKitForNativeColumnHeaderDrag('mouse')).toBe(true);
expect(shouldBypassDndKitForNativeColumnHeaderDrag('touch')).toBe(false);
expect(shouldBypassDndKitForNativeColumnHeaderDrag('pen')).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import { arrayMove } from '@dnd-kit/sortable';
export const DATA_GRID_COLUMN_ORDER_DRAG_MIME = 'application/x-gonavi-data-grid-column-order';
export type DataGridColumnOrderDragPayload = {
scope: string;
columnName: string;
};
export const encodeDataGridColumnOrderDragPayload = (
payload: DataGridColumnOrderDragPayload,
): string => JSON.stringify(payload);
export const decodeDataGridColumnOrderDragPayload = (
rawPayload: string,
): DataGridColumnOrderDragPayload | null => {
try {
const parsed = JSON.parse(String(rawPayload || ''));
const scope = String(parsed?.scope || '').trim();
const columnName = String(parsed?.columnName || '').trim();
return scope && columnName ? { scope, columnName } : null;
} catch {
return null;
}
};
export const hasDataGridColumnOrderDragPayload = (
dataTransfer: Pick<DataTransfer, 'types'> | null | undefined,
): boolean => Array.from(dataTransfer?.types || [])
.some((type) => String(type || '').toLowerCase() === DATA_GRID_COLUMN_ORDER_DRAG_MIME);
// Native HTML drag handles mouse reliably; touch and pen need dnd-kit's PointerSensor.
export const shouldBypassDndKitForNativeColumnHeaderDrag = (pointerType: string): boolean => (
pointerType === 'mouse'
);
export const moveDataGridColumnInVisibleOrder = (
allColumnNames: string[],
hiddenColumnNames: ReadonlySet<string>,
sourceColumnName: string,
targetColumnName: string,
): string[] => {
const source = String(sourceColumnName || '').trim();
const target = String(targetColumnName || '').trim();
if (!source || !target || source === target) return allColumnNames;
const visibleColumnNames = allColumnNames.filter((columnName) => !hiddenColumnNames.has(columnName));
const sourceIndex = visibleColumnNames.indexOf(source);
const targetIndex = visibleColumnNames.indexOf(target);
if (sourceIndex < 0 || targetIndex < 0) return allColumnNames;
const nextVisibleColumnNames = arrayMove(visibleColumnNames, sourceIndex, targetIndex);
let visibleIndex = 0;
return allColumnNames.map((columnName) => (
hiddenColumnNames.has(columnName) ? columnName : nextVisibleColumnNames[visibleIndex++]
));
};

View File

@@ -0,0 +1,163 @@
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 treat update FROM and OUTPUT clauses as SET lists', () => {
const postgresSql = 'UPDATE users SET active = source.active FROM source WHERE users.id = source.user_id';
expect(applyEdit(postgresSql, postgresSql.indexOf(' WHERE'), 'archived_at'))
.toBe('UPDATE users SET active = source.active FROM source archived_at WHERE users.id = source.user_id');
const sqlServerOutputSql = 'UPDATE users SET active = 1 OUTPUT FROM users WHERE users.id = 1';
expect(applyEdit(sqlServerOutputSql, sqlServerOutputSql.indexOf(' FROM'), 'updated_at'))
.toBe('UPDATE users SET active = 1 OUTPUT updated_at FROM users WHERE users.id = 1');
const sqlServerFromSql = 'UPDATE users SET active = 1 FROM users WHERE users.id = 1';
expect(applyEdit(sqlServerFromSql, sqlServerFromSql.indexOf(' WHERE'), 'audit'))
.toBe('UPDATE users SET active = 1 FROM users audit WHERE users.id = 1');
});
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');
});
});

View 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'
&& ['from', 'output', '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 ? ' ' : ''}`,
};
};