feat(query-editor): 增强 SQL 编辑器执行与历史体验

- 支持仅执行选中 SQL、光标所在语句和增量新增语句

- 持久化查询草稿,避免重启后丢失历史 SQL

- 在表字段提示中展示注释信息

- 修复清空默认 SQL 后被自动回填的问题

Refs #483
This commit is contained in:
Syngnat
2026-05-23 17:07:47 +08:00
parent 09af56b1c2
commit b9c743d67e
20 changed files with 1431 additions and 119 deletions

View File

@@ -23,10 +23,11 @@ const storeState = vi.hoisted(() => ({
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
updateQueryTabDraft: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
appearance: { uiVersion: 'legacy' as const },
appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' },
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
@@ -47,6 +48,7 @@ const storeState = vi.hoisted(() => ({
}));
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
@@ -74,25 +76,82 @@ const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
position: { lineNumber: 1, column: 1 },
selection: null as any,
providers: [] as any[],
cursorPositionListeners: [] as Array<(event: any) => void>,
hasTextFocus: true,
};
const offsetAt = (position: { lineNumber: number; column: number }) => {
const text = state.value;
let offset = 0;
for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) {
const nextLineBreak = text.indexOf('\n', offset);
if (nextLineBreak === -1) {
return text.length;
}
offset = nextLineBreak + 1;
}
return Math.min(text.length, offset + Math.max(0, position.column - 1));
};
const positionAt = (offset: number) => {
const text = state.value.replace(/\r\n/g, '\n');
const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0));
const prefix = text.slice(0, safeOffset);
const lines = prefix.split('\n');
return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 };
};
const valueInRange = (range: any) => {
if (!range) return '';
const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn });
const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn });
return state.value.slice(Math.min(start, end), Math.max(start, end));
};
const model = {
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: valueInRange,
getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '',
getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length,
getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1,
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1, word: '' }),
getOffsetAt: offsetAt,
getPositionAt: positionAt,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
getModel: vi.fn(() => model),
getPosition: vi.fn(() => state.position),
setPosition: vi.fn((position: any) => {
state.position = position;
}),
getSelection: vi.fn(() => state.selection),
setSelection: vi.fn((selection: any) => {
state.selection = selection;
}),
executeEdits: vi.fn((_source: string, edits: any[]) => {
edits.forEach((edit) => {
const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn });
const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn });
state.value = state.value.slice(0, start) + edit.text + state.value.slice(end);
});
}),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => {
state.cursorPositionListeners.push(listener);
return { dispose: vi.fn() };
}),
hasTextFocus: vi.fn(() => state.hasTextFocus),
revealLineInCenterIfOutsideViewport: vi.fn(),
revealRangeInCenterIfOutsideViewport: vi.fn(),
focus: vi.fn(),
trigger: vi.fn(),
};
return state;
});
@@ -119,7 +178,31 @@ vi.mock('@monaco-editor/react', () => ({
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
registerCompletionItemProvider: vi.fn((_language: string, provider: any) => {
editorState.providers.push(provider);
return { dispose: vi.fn() };
}),
},
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
Position: class {
lineNumber: number;
column: number;
constructor(lineNumber: number, column: number) {
this.lineNumber = lineNumber;
this.column = column;
}
},
});
}, []);
@@ -145,12 +228,13 @@ vi.mock('@ant-design/icons', () => {
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
DatabaseOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
const Button: any = ({ children, disabled, loading, onClick, onMouseDown, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} onMouseDown={onMouseDown} {...rest}>
{children}
</button>
);
@@ -169,7 +253,15 @@ vi.mock('antd', () => {
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: ({ items }: any) => <div>{items?.[0]?.children}</div>,
Tabs: ({ activeKey, items }: any) => {
const activeItem = items?.find((item: any) => item.key === activeKey) || items?.[0];
return (
<div>
<div>{items?.map((item: any) => <span key={item.key}>{item.label}</span>)}</div>
<div>{activeItem?.children}</div>
</div>
);
},
};
});
@@ -205,6 +297,7 @@ describe('QueryEditor external SQL save', () => {
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
@@ -212,10 +305,18 @@ describe('QueryEditor external SQL save', () => {
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
storeState.appearance.uiVersion = 'legacy';
dataGridState.latestProps = null;
editorState.value = '';
editorState.position = { lineNumber: 1, column: 1 };
editorState.selection = null;
editorState.providers = [];
editorState.cursorPositionListeners = [];
editorState.hasTextFocus = true;
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
editorState.editor.executeEdits.mockClear();
storeState.updateQueryTabDraft.mockReset();
});
afterEach(() => {
@@ -223,6 +324,29 @@ describe('QueryEditor external SQL save', () => {
vi.clearAllMocks();
});
it('shows the default SQL template for a fresh blank query tab', async () => {
await act(async () => {
create(<QueryEditor tab={createTab({ query: '' })} />);
});
expect(editorState.value).toBe('SELECT * FROM ');
});
it('keeps the editor empty when a tab draft is externally synced to an empty query', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT * FROM ' })} />);
});
await act(async () => {
renderer.update(<QueryEditor tab={createTab({ query: '' })} />);
});
expect(editorState.value).toBe('');
expect(editorState.editor.setValue).toHaveBeenCalledWith('');
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
@@ -538,6 +662,467 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
});
it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: expect.stringContaining('select 2 as two'),
}));
});
it('keeps cursor statement execution available in v2 UI', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('uses the last editor cursor position when the run button takes focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = false;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('prefers the last editor cursor event even if Monaco still reports text focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = true;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
editorState.position = { lineNumber: 3, column: 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\r\nselect 2 as b;\r\n\r\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('does not execute SQL when the cursor is on a blank line', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 3, column: 1 };
editorState.selection = {
startLineNumber: 3,
startColumn: 1,
endLineNumber: 3,
endColumn: 1,
positionLineNumber: 3,
positionColumn: 1,
};
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 3, column: 1 } });
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(messageApi.info).toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('runs only appended SQL and keeps existing results after a full editor execution', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.value = 'select 1 as a;\nselect 2 as b;';
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)');
expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)');
});
it('replaces the current result when rerunning the same cursor SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 10 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 '));
expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)');
expect(textContent(renderer!.toJSON())).not.toContain('结果 2 (1)');
expect(tabLabels.length).toBeGreaterThan(0);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })]));
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b');
});
it('appends a result when running a different cursor SQL after an existing result', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c');
expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)');
expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)');
});
it('runs selected SQL before cursor SQL', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['selected'], rows: [{ selected: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as selected;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 1, column: 4 };
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select 2 as selected'.length + 1,
};
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,

View File

@@ -6,7 +6,7 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
@@ -17,7 +17,7 @@ import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSql
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
import { quoteIdentPart } from '../utils/sql';
import { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection';
import { resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection';
import { isMacLikePlatform } from '../utils/appearance';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
@@ -193,8 +193,10 @@ let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
let sharedCurrentDb = '';
let sharedCurrentConnectionId = '';
let sharedConnections: any[] = [];
let sharedTablesData: {dbName: string, tableName: string}[] = [];
let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type: string}[] = [];
type CompletionTableMeta = {dbName: string, tableName: string, comment?: string};
type CompletionColumnMeta = {dbName: string, tableName: string, name: string, type: string, comment?: string};
let sharedTablesData: CompletionTableMeta[] = [];
let sharedAllColumnsData: CompletionColumnMeta[] = [];
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
@@ -469,6 +471,176 @@ const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string
`${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
);
const escapeMetadataSqlLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerDbIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const normalizeMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim();
const dialect = resolveSqlDialect(type, driver, {
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
});
if (dialect === 'diros' || dialect === 'sphinx' || dialect === 'mariadb' || dialect === 'oceanbase') return 'mysql';
if (dialect === 'dameng') return 'oracle';
return String(dialect || '').toLowerCase();
};
const buildCompletionTableCommentSQL = (dialect: string, dbName: string): string => {
const db = String(dbName || '').trim();
const escapedDb = escapeMetadataSqlLiteral(db);
switch (dialect) {
case 'mysql':
case 'starrocks':
return `SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment FROM information_schema.tables WHERE table_schema = '${escapedDb}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo':
case 'opengauss':
return `SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, c.relname`;
case 'sqlserver': {
const safeDb = quoteSqlServerDbIdentifier(db);
return `SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment FROM ${safeDb}.sys.tables t JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN ${safeDb}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' WHERE t.type = 'U' ORDER BY s.name, t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment FROM system.tables WHERE database = '${escapedDb}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'oracle': {
const owner = escapedDb.toUpperCase();
return `SELECT table_name, comments AS table_comment FROM all_tab_comments WHERE owner = '${owner}' ORDER BY table_name`;
}
default:
return '';
}
};
const getCaseInsensitiveValue = (row: Record<string, any>, keys: string[]): any => {
for (const key of keys) {
for (const rowKey of Object.keys(row || {})) {
if (rowKey.toLowerCase() === key.toLowerCase()) {
return row[rowKey];
}
}
}
return undefined;
};
const normalizeCommentText = (value: unknown): string => {
if (value === null || value === undefined) return '';
const text = String(value).trim();
if (!text || text.toLowerCase() === '<nil>') return '';
return text;
};
const buildCompletionDocumentation = (comment?: string): string | undefined => {
const text = normalizeCommentText(comment);
return text ? `备注:${text}` : undefined;
};
const appendCommentToDetail = (detail: string, comment?: string): string => {
const text = normalizeCommentText(comment);
return text ? `${detail} - ${text}` : detail;
};
const stripCompletionIdentifierQuotes = (ident: string): string => {
let raw = String(ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '`' && last === '`') || (first === '"' && last === '"')) {
raw = raw.slice(1, -1);
}
return raw.trim();
};
const normalizeCompletionQualifiedName = (ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
return raw
.split('.')
.map(p => stripCompletionIdentifierQuotes(p.trim()))
.filter(Boolean)
.join('.');
};
const getCompletionQualifiedNameLastPart = (qualified: string): string => {
const raw = normalizeCompletionQualifiedName(qualified);
if (!raw) return raw;
const parts = raw.split('.').filter(Boolean);
return parts[parts.length - 1] || raw;
};
const splitCompletionSchemaAndTable = (qualified: string): { schema: string; table: string } => {
const raw = normalizeCompletionQualifiedName(qualified);
if (!raw) return { schema: '', table: '' };
const parts = raw.split('.').filter(Boolean);
if (parts.length >= 2) {
return {
schema: parts[parts.length - 2] || '',
table: parts[parts.length - 1] || '',
};
}
return { schema: '', table: parts[0] || '' };
};
const DEFAULT_QUERY_TEMPLATE = 'SELECT * FROM ';
const getTabQueryValue = (tab: TabData): string => (
typeof tab.query === 'string' ? tab.query : ''
);
const getInitialEditorQuery = (tab: TabData): string => {
const tabQuery = getTabQueryValue(tab);
if (tabQuery || tab.filePath || tab.savedQueryId || tab.readOnly) {
return tabQuery;
}
return DEFAULT_QUERY_TEMPLATE;
};
const resolveNextResultSetIndex = (sets: Array<{ key?: string }>): number => {
const maxIndex = sets.reduce((max, item) => {
const match = String(item?.key || '').match(/^result-(\d+)$/);
const index = match ? Number(match[1]) : 0;
return Number.isFinite(index) ? Math.max(max, index) : max;
}, 0);
return maxIndex + 1;
};
const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => (
left.length === right.length && left.every((statement, index) => statement === right[index])
);
const normalizeExecutedSqlKey = (sql: string): string => String(sql || '')
.replace(/\r\n/g, '\n')
.replace(//g, ';')
.trim()
.replace(/;+\s*$/g, '')
.trim();
const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => {
if (!position) return null;
const lineNumber = Number(position.positionLineNumber ?? position.lineNumber ?? position.endLineNumber ?? position.startLineNumber ?? position.selectionStartLineNumber);
const column = Number(position.positionColumn ?? position.column ?? position.endColumn ?? position.startColumn ?? position.selectionStartColumn);
if (!Number.isFinite(lineNumber) || !Number.isFinite(column) || lineNumber < 1 || column < 1) {
return null;
}
return { lineNumber, column };
};
const getNormalizedOffsetAtPosition = (
sqlText: string,
position: { lineNumber: number; column: number },
): number => {
const text = String(sqlText || '').replace(/\r\n/g, '\n');
const lines = text.split('\n');
const targetLineIndex = Math.max(0, Math.min(lines.length - 1, position.lineNumber - 1));
let offset = 0;
for (let index = 0; index < targetLineIndex; index++) {
offset += (lines[index]?.length || 0) + 1;
}
return Math.max(0, Math.min(text.length, offset + Math.max(0, position.column - 1)));
};
const resolveQueryLocatorPlan = async ({
statement,
dbType,
@@ -610,7 +782,7 @@ const resolveQueryLocatorPlan = async ({
};
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
const [query, setQuery] = useState(getInitialEditorQuery(tab));
type ResultSet = {
key: string;
@@ -648,12 +820,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const monacoRef = useRef<any>(null);
const runQueryActionRef = useRef<any>(null);
const selectCurrentStatementActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(tab.query || '');
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
const lastEditorCursorPositionRef = useRef<any>(null);
const lastExecutedEditorQueryRef = useRef<string>('');
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
const editorPaneRef = useRef<HTMLDivElement | null>(null);
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
const tablesRef = useRef<CompletionTableMeta[]>([]); // Store tables for autocomplete (cross-db)
const allColumnsRef = useRef<CompletionColumnMeta[]>([]); // Store all columns (cross-db)
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
const connections = useStore(state => state.connections);
@@ -663,6 +837,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
);
const addSqlLog = useStore(state => state.addSqlLog);
const addTab = useStore(state => state.addTab);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
const savedQueries = useStore(state => state.savedQueries);
const currentConnectionIdRef = useRef(currentConnectionId);
const currentDbRef = useRef(currentDb);
@@ -730,6 +905,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
currentDbRef.current = currentDb;
}, [currentDb]);
useEffect(() => {
updateQueryTabDraft(tab.id, {
query,
connectionId: currentConnectionId,
dbName: currentDb,
});
}, [currentConnectionId, currentDb, query, tab.id, updateQueryTabDraft]);
// 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量
// 确保 completion provider 始终使用当前活跃 Tab 的上下文
useEffect(() => {
@@ -789,12 +972,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// If opening a saved query, load its SQL
useEffect(() => {
const incoming = tab.query || '';
const incoming = getTabQueryValue(tab);
if (incoming === lastExternalQueryRef.current) {
return;
}
lastExternalQueryRef.current = incoming;
syncQueryToEditor(incoming || 'SELECT * FROM ');
syncQueryToEditor(incoming);
}, [tab.id, tab.query]);
// Fetch Database List
@@ -871,16 +1054,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
// 加载所有可见数据库的表
const allTables: {dbName: string, tableName: string}[] = [];
const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = [];
const allTables: CompletionTableMeta[] = [];
const allColumns: CompletionColumnMeta[] = [];
const metadataDialect = normalizeMetadataDialect(conn);
for (const dbName of visibleDbs) {
const tableComments = new Map<string, string>();
const tableCommentSQL = buildCompletionTableCommentSQL(metadataDialect, dbName);
if (tableCommentSQL) {
try {
const resTableComments = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, tableCommentSQL);
if (resTableComments.success && Array.isArray(resTableComments.data)) {
resTableComments.data.forEach((row: any) => {
const tableName = normalizeCommentText(getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'name', 'Name']));
if (!tableName) return;
tableComments.set(tableName.toLowerCase(), normalizeCommentText(getCaseInsensitiveValue(row, ['table_comment', 'TABLE_COMMENT', 'comment', 'comments', 'Comment', 'COMMENTS'])));
});
}
} catch {
// 表备注只是补全增强,失败时保留原有表名补全。
}
}
// 获取表
const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tableNames.forEach((tableName: string) => {
allTables.push({ dbName, tableName });
const parsed = splitCompletionSchemaAndTable(String(tableName || ''));
allTables.push({
dbName,
tableName,
comment: tableComments.get(String(tableName || '').toLowerCase())
|| (parsed.table ? tableComments.get(parsed.table.toLowerCase()) : undefined)
|| undefined
});
});
}
@@ -892,7 +1100,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName,
tableName: col.tableName,
name: col.name,
type: col.type
type: col.type,
comment: normalizeCommentText(col.comment ?? col.Comment ?? col.COLUMN_COMMENT ?? col.column_comment ?? '')
});
});
}
@@ -945,10 +1154,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const handleEditorDidMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
editor.onDidChangeCursorPosition?.((event: any) => {
const position = normalizeEditorPosition(event?.position);
if (position) {
lastEditorCursorPositionRef.current = position;
}
});
// 注册 AI 右键菜单操作
const aiActions = [
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
@@ -1039,46 +1256,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
const stripQuotes = (ident: string) => {
let raw = (ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '`' && last === '`') || (first === '"' && last === '"')) {
raw = raw.slice(1, -1);
}
return raw.trim();
};
const normalizeQualifiedName = (ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
return raw
.split('.')
.map(p => stripQuotes(p.trim()))
.filter(Boolean)
.join('.');
};
const getLastPart = (qualified: string) => {
const raw = normalizeQualifiedName(qualified);
if (!raw) return raw;
const parts = raw.split('.').filter(Boolean);
return parts[parts.length - 1] || raw;
};
const splitSchemaAndTable = (qualified: string): { schema: string; table: string } => {
const raw = normalizeQualifiedName(qualified);
if (!raw) return { schema: '', table: '' };
const parts = raw.split('.').filter(Boolean);
if (parts.length >= 2) {
return {
schema: parts[parts.length - 2] || '',
table: parts[parts.length - 1] || '',
};
}
return { schema: '', table: parts[0] || '' };
};
const stripQuotes = stripCompletionIdentifierQuotes;
const normalizeQualifiedName = normalizeCompletionQualifiedName;
const getLastPart = getCompletionQualifiedNameLastPart;
const splitSchemaAndTable = splitCompletionSchemaAndTable;
const buildConnConfig = () => {
const connId = sharedCurrentConnectionId;
@@ -1140,7 +1321,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.dbName}.${c.tableName})`,
detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment),
documentation: buildCompletionDocumentation(c.comment),
range,
sortText: '0' + c.name
}));
@@ -1169,7 +1351,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label: t.tableName,
kind: monaco.languages.CompletionItemKind.Class,
insertText: t.tableName,
detail: `Table (${t.dbName})`,
detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: '0' + t.tableName
}));
@@ -1184,6 +1367,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName: t.dbName || '',
schema: parsed.schema,
table: parsed.table,
comment: t.comment,
};
})
.filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table);
@@ -1197,7 +1381,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label: t.table,
kind: monaco.languages.CompletionItemKind.Class,
insertText: t.table,
detail: `Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`,
detail: appendCommentToDetail(`Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: '0' + t.table
}));
@@ -1242,7 +1427,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
let cols: { name: string, type?: string, tableName?: string, dbName?: string, comment?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
@@ -1254,10 +1439,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName, comment: c.comment }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName }));
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName, comment: c.comment }));
}
const filtered = prefix
@@ -1268,7 +1453,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
detail: appendCommentToDetail(
c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
c.comment,
),
documentation: buildCompletionDocumentation(c.comment),
range,
sortText: '0' + c.name
}));
@@ -1318,7 +1507,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.dbName}.${c.tableName})`,
detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment),
documentation: buildCompletionDocumentation(c.comment),
range,
sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name,
};
@@ -1358,7 +1548,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: label,
detail: `Table (${t.dbName})`,
detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: sortGroups.tableOther + t.tableName,
};
@@ -1376,7 +1567,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: `Table${schemaInfo}`,
detail: appendCommentToDetail(`Table${schemaInfo}`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: sortGroups.tableCurrent + pureTable,
};
@@ -1747,6 +1939,89 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return selected;
};
const mergeResultSets = (previous: ResultSet[], next: ResultSet[], replaceAll: boolean): ResultSet[] => {
if (replaceAll || previous.length === 0) {
return next.map((result, index) => ({ ...result, key: `result-${index + 1}` }));
}
const merged = [...previous];
next.forEach((result) => {
const incomingKey = normalizeExecutedSqlKey(result.exportSql || result.sql);
const existingIndex = merged.findIndex((item) => normalizeExecutedSqlKey(item.exportSql || item.sql) === incomingKey);
if (existingIndex >= 0) {
merged[existingIndex] = { ...result, key: merged[existingIndex].key };
return;
}
merged.push({ ...result, key: `result-${resolveNextResultSetIndex(merged)}` });
});
return merged;
};
const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any): string => {
const normalizedPosition = normalizeEditorPosition(position);
if (!normalizedPosition) return '';
const cursorOffset = getNormalizedOffsetAtPosition(sqlText, normalizedPosition);
const resolved = resolveExecutableSql(sqlText, cursorOffset, '');
return resolved?.sql || '';
};
const getExecutableSQLAtCurrentCursor = (model: any, sqlText: string): string => {
const editor = editorRef.current;
const liveSelection = normalizeEditorPosition(editor?.getSelection?.());
if (liveSelection) {
return resolveExecutableSQLAtEditorPosition(model, sqlText, liveSelection);
}
const livePosition = normalizeEditorPosition(editor?.getPosition?.());
const cachedPosition = normalizeEditorPosition(lastEditorCursorPositionRef.current);
const candidates: Array<{ lineNumber: number; column: number }> = [];
if (cachedPosition) candidates.push(cachedPosition);
if (livePosition) candidates.push(livePosition);
const seen = new Set<string>();
for (const position of candidates) {
const key = `${position.lineNumber}:${position.column}`;
if (seen.has(key)) continue;
seen.add(key);
const sql = resolveExecutableSQLAtEditorPosition(model, sqlText, position);
if (sql.trim()) return sql;
}
const fallbackPosition = cachedPosition || livePosition;
return resolveExecutableSQLAtEditorPosition(model, sqlText, fallbackPosition);
};
const getExecutableSQL = (): string => {
const editor = editorRef.current;
const model = editor?.getModel?.();
const currentQuery = getCurrentQuery();
const selectedSQL = getSelectedSQL();
const selected = selectedSQL.trim();
if (!selected && resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current)) {
const appendedSQL = currentQuery.slice(lastExecutedEditorQueryRef.current.length);
if (appendedSQL.trim()) {
return appendedSQL;
}
}
if (!model || !editor) {
return selectedSQL || currentQuery;
}
if (selected) {
return selectedSQL;
}
return getExecutableSQLAtCurrentCursor(model, String(model.getValue?.() ?? currentQuery));
};
const captureEditorCursorPosition = (event?: React.MouseEvent<HTMLElement>) => {
event?.preventDefault();
const editor = editorRef.current;
const position = normalizeEditorPosition(editor?.getSelection?.()) || normalizeEditorPosition(editor?.getPosition?.());
if (position) {
lastEditorCursorPositionRef.current = position;
}
};
// 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL
const handleReloadResult = async (resultKey: string, sql: string) => {
if (!sql?.trim() || !currentDb) return;
@@ -1816,6 +2091,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const handleRun = async () => {
const currentQuery = getCurrentQuery();
if (!currentQuery.trim()) return;
const executableSQL = getExecutableSQL();
if (!executableSQL.trim()) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
if (!currentDb) {
message.error("请先选择数据库");
return;
@@ -1858,7 +2140,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
try {
const rawSQL = getSelectedSQL() || currentQuery;
const rawSQL = executableSQL;
const rpcConfig = buildRpcConnectionConfig(config) as any;
const dbType = String(rpcConfig.type || 'mysql');
const driver = String((config as any).driver || '');
@@ -1876,6 +2158,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
.replace(/^\s*\/\/.*$/gm, '')
.replace(/^\s*#.*$/gm, '');
const statements = splitSQLStatements(splitInput);
const didExecuteAppendedSql = resultSets.length > 0
&& lastExecutedEditorQueryRef.current
&& currentQuery.startsWith(lastExecutedEditorQueryRef.current)
&& normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(//g, ';').trim();
const didExecuteWholeEditor = areSqlStatementListsEqual(
splitSQLStatements(currentQuery.replace(//g, ';')),
statements,
);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
@@ -1980,8 +2270,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
}
setResultSets(nextResultSets);
const shouldReplaceAllResults = didExecuteWholeEditor;
setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults));
setActiveResultKey(nextResultSets[0]?.key || '');
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
@@ -1991,6 +2285,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
} else {
// 非 MongoDB使用 DBQueryMulti 一次性执行多条 SQL后端返回多结果集
const sourceStatements = splitSQLStatements(normalizedRawSQL);
const didExecuteAppendedSql = resultSets.length > 0
&& lastExecutedEditorQueryRef.current
&& currentQuery.startsWith(lastExecutedEditorQueryRef.current)
&& normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(//g, ';').trim();
const didExecuteWholeEditor = areSqlStatementListsEqual(
splitSQLStatements(currentQuery.replace(//g, ';')),
sourceStatements,
);
if (sourceStatements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
@@ -2136,8 +2438,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
setResultSets(nextResultSets);
const shouldReplaceAllResults = didExecuteWholeEditor;
setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults));
setActiveResultKey(nextResultSets[0]?.key || '');
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
executablePlans.forEach((plan) => {
if (plan.warning) message.warning(plan.warning);
@@ -2159,7 +2465,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: getSelectedSQL() || query,
sql: executableSQL || getExecutableSQL() || query,
status: 'error',
duration: Date.now() - runStartTime,
message: e.message,
@@ -2395,6 +2701,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
text: (position.column > 1 ? '\n' : '') + mText,
forceMoveMarkers: true
}]);
const nextValue = editor.getValue?.();
if (typeof nextValue === 'string') {
setQuery(nextValue);
}
// 定位并滚动到可见区域
const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0);
@@ -2626,7 +2936,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
: '运行'
}
>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
<Button type="primary" icon={<PlayCircleOutlined />} onMouseDown={captureEditorCursorPosition} onClick={handleRun} loading={loading}>
</Button>
</Tooltip>

View File

@@ -566,6 +566,125 @@ describe('store appearance persistence', () => {
]);
});
it('persists open query tab drafts and restores them after reload', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'query-tab-1',
title: '临时 SQL',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select * from users where id = 1;',
});
useStore.getState().updateQueryTabDraft('query-tab-1', {
query: 'select * from orders where status = "paid";',
connectionId: 'conn-2',
dbName: 'reporting',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.tabs).toEqual([
expect.objectContaining({
id: 'query-tab-1',
title: '临时 SQL',
type: 'query',
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
}),
]);
expect(persisted.state.activeTabId).toBe('query-tab-1');
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().tabs).toEqual([
expect.objectContaining({
id: 'query-tab-1',
type: 'query',
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
}),
]);
expect(reloaded.useStore.getState().activeTabId).toBe('query-tab-1');
});
it('only restores persisted query tabs with useful SQL state', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
tabs: [
{
id: 'query-1',
title: '有效 SQL',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
},
{
id: 'table-1',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
},
{
id: 'empty-query',
title: '空查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: ' ',
},
],
activeTabId: 'table-1',
},
version: 9,
}));
const { useStore } = await importStore();
expect(useStore.getState().tabs).toEqual([
expect.objectContaining({
id: 'query-1',
type: 'query',
query: 'select 1;',
}),
]);
expect(useStore.getState().activeTabId).toBe('query-1');
});
it('persists recent SQL execution logs and trims oversized entries', async () => {
const { useStore } = await importStore();
const longSql = `select '${'x'.repeat(120 * 1024)}'`;
useStore.getState().addSqlLog({
id: 'log-1',
timestamp: 100,
sql: longSql,
status: 'success',
duration: 12,
dbName: 'main',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.sqlLogs).toHaveLength(1);
expect(persisted.state.sqlLogs[0].sql.length).toBe(100 * 1024);
expect(persisted.state.sqlLogs[0].dbName).toBe('main');
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({
id: 'log-1',
status: 'success',
duration: 12,
dbName: 'main',
}));
expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(100 * 1024);
});
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
const { useStore } = await importStore();

View File

@@ -76,6 +76,12 @@ const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 9;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const MAX_PERSISTED_QUERY_TABS = 20;
const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024;
const MAX_SQL_LOGS = 1000;
const MAX_PERSISTED_SQL_LOGS = 200;
const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024;
const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024;
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
@@ -852,6 +858,10 @@ interface AppState {
reorderTags: (tagIds: string[]) => void;
addTab: (tab: TabData) => void;
updateQueryTabDraft: (
id: string,
draft: Partial<Pick<TabData, "query" | "connectionId" | "dbName" | "title">>,
) => void;
closeTab: (id: string) => void;
closeOtherTabs: (id: string) => void;
closeTabsToLeft: (id: string) => void;
@@ -1050,6 +1060,97 @@ const sanitizeExternalSQLDirectories = (
return result;
};
const sanitizeQueryTabs = (value: unknown): TabData[] => {
if (!Array.isArray(value)) return [];
const result: TabData[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
if (raw.type !== "query") return;
const query = typeof raw.query === "string" ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : "";
const filePath = toTrimmedString(raw.filePath);
const savedQueryId = toTrimmedString(raw.savedQueryId);
if (!query.trim() && !filePath && !savedQueryId) return;
let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
if (seenIds.has(id)) {
id = `${id}-${index + 1}`;
}
seenIds.add(id);
result.push({
id,
title: toTrimmedString(raw.title, "新建查询") || "新建查询",
type: "query",
connectionId: toTrimmedString(raw.connectionId),
dbName: toTrimmedString(raw.dbName),
query,
filePath: filePath || undefined,
savedQueryId: savedQueryId || undefined,
readOnly: raw.readOnly === true,
});
});
return result.slice(0, MAX_PERSISTED_QUERY_TABS);
};
const sanitizeActiveTabId = (activeTabId: unknown, tabs: TabData[]): string | null => {
const id = toTrimmedString(activeTabId);
if (id && tabs.some((tab) => tab.id === id)) {
return id;
}
return tabs[0]?.id || null;
};
const sanitizeSqlLogs = (value: unknown, limit = MAX_PERSISTED_SQL_LOGS): SqlLog[] => {
if (!Array.isArray(value)) return [];
const result: SqlLog[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
const sql = typeof raw.sql === "string" ? raw.sql.slice(0, MAX_PERSISTED_SQL_LOG_LENGTH) : "";
if (!sql.trim()) return;
let id = toTrimmedString(raw.id, `log-${index + 1}`) || `log-${index + 1}`;
if (seenIds.has(id)) {
id = `${id}-${index + 1}`;
}
seenIds.add(id);
const status = raw.status === "error" ? "error" : "success";
const timestamp = Number(raw.timestamp);
const duration = Number(raw.duration);
const affectedRows = Number(raw.affectedRows);
const log: SqlLog = {
id,
timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now(),
sql,
status,
duration: Number.isFinite(duration) && duration >= 0 ? duration : 0,
dbName: toTrimmedString(raw.dbName) || undefined,
};
const message = typeof raw.message === "string"
? raw.message.slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH)
: "";
if (message) {
log.message = message;
}
if (Number.isFinite(affectedRows)) {
log.affectedRows = affectedRows;
}
result.push(log);
});
return result.slice(0, limit);
};
const hasLegacyConnectionSecrets = (
connections: SavedConnection[],
): boolean => {
@@ -1649,6 +1750,51 @@ export const useStore = create<AppState>()(
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
}),
updateQueryTabDraft: (id, draft) =>
set((state) => {
const tabId = toTrimmedString(id);
if (!tabId) return state;
let changed = false;
const nextTabs = state.tabs.map((tab) => {
if (tab.id !== tabId || tab.type !== "query") return tab;
const nextTab: TabData = { ...tab };
if (draft.query !== undefined) {
const nextQuery = typeof draft.query === "string" ? draft.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : "";
if (nextTab.query !== nextQuery) {
nextTab.query = nextQuery;
changed = true;
}
}
if (draft.connectionId !== undefined) {
const nextConnectionId = toTrimmedString(draft.connectionId);
if (nextTab.connectionId !== nextConnectionId) {
nextTab.connectionId = nextConnectionId;
changed = true;
}
}
if (draft.dbName !== undefined) {
const nextDbName = toTrimmedString(draft.dbName);
if ((nextTab.dbName || "") !== nextDbName) {
nextTab.dbName = nextDbName;
changed = true;
}
}
if (draft.title !== undefined) {
const nextTitle = toTrimmedString(draft.title, nextTab.title) || nextTab.title;
if (nextTab.title !== nextTitle) {
nextTab.title = nextTitle;
changed = true;
}
}
return nextTab;
});
return changed ? { tabs: nextTabs } : state;
}),
closeTab: (id) =>
set((state) => {
const newTabs = state.tabs.filter((t) => t.id !== id);
@@ -1926,7 +2072,7 @@ export const useStore = create<AppState>()(
}),
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
set((state) => ({ sqlLogs: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_LOGS) })),
clearSqlLogs: () => set({ sqlLogs: [] }),
recordTableAccess: (connectionId, dbName, tableName) =>
@@ -2245,6 +2391,9 @@ export const useStore = create<AppState>()(
) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = sanitizeConnections(state.connections);
const safeTabs = sanitizeQueryTabs(state.tabs);
nextState.tabs = safeTabs;
nextState.activeTabId = sanitizeActiveTabId(state.activeTabId, safeTabs);
if (version < 5) {
nextState.connectionTags = sanitizeConnectionTags(
state.connectionTags,
@@ -2273,6 +2422,7 @@ export const useStore = create<AppState>()(
nextState.shortcutOptions = sanitizeShortcutOptions(
state.shortcutOptions,
);
nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs);
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
@@ -2318,11 +2468,14 @@ export const useStore = create<AppState>()(
const state = unwrapPersistedAppState(
persistedState,
) as Partial<AppState>;
const safeTabs = sanitizeQueryTabs(state.tabs);
return {
...currentState,
...state,
connections: sanitizeConnections(state.connections),
connectionTags: sanitizeConnectionTags(state.connectionTags),
tabs: safeTabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
savedQueries: sanitizeSavedQueries(state.savedQueries),
externalSQLDirectories: sanitizeExternalSQLDirectories(
state.externalSQLDirectories,
@@ -2352,6 +2505,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
@@ -2361,7 +2515,10 @@ export const useStore = create<AppState>()(
};
},
partialize: (state) => {
const tabs = sanitizeQueryTabs(state.tabs);
const partialState: Partial<AppState> = {
tabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, tabs),
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
externalSQLDirectories: state.externalSQLDirectories,
@@ -2377,6 +2534,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { findSqlStatementRanges, resolveCurrentSqlStatementRange } from './sqlStatementSelection';
import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from './sqlStatementSelection';
describe('sqlStatementSelection', () => {
it('resolves the statement containing the cursor', () => {
@@ -38,4 +38,46 @@ describe('sqlStatementSelection', () => {
it('returns null when there is no statement', () => {
expect(resolveCurrentSqlStatementRange(' \n\t ', 0)).toBeNull();
});
it('prefers a non-empty selection for executable SQL', () => {
const sql = 'select 1;\nselect 2;';
expect(resolveExecutableSql(sql, sql.indexOf('1'), ' select selected ')?.sql).toBe(' select selected ');
expect(resolveExecutableSql(sql, sql.indexOf('1'), ' select selected ')?.source).toBe('selection');
});
it('uses the statement containing the cursor for executable SQL', () => {
const sql = 'select 1;\n\nselect 2 from users;\nselect 3';
expect(resolveExecutableSql(sql, sql.indexOf('users'))).toEqual({
sql: 'select 2 from users',
source: 'statement',
});
});
it('keeps execution on the statement when the cursor lands after its semicolon', () => {
const sql = 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;';
const afterSecondSemicolon = sql.indexOf('select 2 as b') + 'select 2 as b;'.length;
expect(resolveExecutableSql(sql, afterSecondSemicolon)).toEqual({
sql: 'select 2 as b',
source: 'statement',
});
});
it('falls back to the current line when the cursor is not inside a statement', () => {
const sql = 'select 1;\n\n select 2';
expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull();
expect(resolveExecutableSql(sql, sql.indexOf(' select 2'))).toEqual({
sql: 'select 2',
source: 'statement',
});
});
it('does not jump to the next statement when executing from blank space', () => {
const sql = 'select 1;\n\nselect 2;';
expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull();
});
});

View File

@@ -4,6 +4,13 @@ export interface SqlStatementRange {
text: string;
}
export type SqlExecutionSelectionSource = 'selection' | 'statement' | 'line';
export interface SqlExecutionSelection {
sql: string;
source: SqlExecutionSelectionSource;
}
const isWhitespace = (ch: string): boolean => (
ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
);
@@ -157,3 +164,38 @@ export const resolveCurrentSqlStatementRange = (sql: string, cursorOffset: numbe
return ranges[ranges.length - 1];
};
export const resolveExecutableSql = (
sql: string,
cursorOffset: number,
selectedSql = '',
): SqlExecutionSelection | null => {
const selected = String(selectedSql || '').trim();
if (selected) {
return { sql: selectedSql, source: 'selection' };
}
const text = String(sql || '').replace(/\r\n/g, '\n');
const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0));
const ranges = findSqlStatementRanges(text);
const statement = ranges.find((range) => offset >= range.start && offset <= range.end);
if (statement?.text.trim()) {
return { sql: statement.text, source: 'statement' };
}
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
const nextLineBreak = text.indexOf('\n', offset);
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak;
const line = text.slice(lineStart, lineEnd).trim();
if (line) {
const lineStatement = [...ranges].reverse().find((range) => range.start < lineEnd && range.end >= lineStart);
if (lineStatement?.text.trim()) {
return { sql: lineStatement.text, source: 'statement' };
}
}
if (line) {
return { sql: line, source: 'line' };
}
return null;
};

View File

@@ -178,6 +178,7 @@ type ColumnDefinitionWithTable struct {
TableName string `json:"tableName"`
Name string `json:"name"`
Type string `json:"type"`
Comment string `json:"comment,omitempty"`
}
// UpdateRow 表示一行更新操作Keys 为 WHERE 条件Values 为 SET 值。

View File

@@ -925,7 +925,8 @@ SELECT
database,
table,
name,
type
type,
comment
FROM system.columns
WHERE database = '%s'
ORDER BY table, position`,
@@ -937,7 +938,8 @@ SELECT
database,
table,
name,
type
type,
comment
FROM system.columns
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
ORDER BY database, table, position`
@@ -954,6 +956,7 @@ ORDER BY database, table, position`
tableValue, hasTable := getClickHouseValueFromRow(row, "table", "table_name")
nameValue, hasName := getClickHouseValueFromRow(row, "name", "column_name")
typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type")
commentValue, _ := getClickHouseValueFromRow(row, "comment")
if !hasTable || !hasName {
continue
}
@@ -970,6 +973,7 @@ ORDER BY database, table, position`
TableName: tableName,
Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)),
Type: strings.TrimSpace(fmt.Sprintf("%v", typeValue)),
Comment: strings.TrimSpace(fmt.Sprintf("%v", commentValue)),
})
}
return result, nil

View File

@@ -480,9 +480,11 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf(`SELECT table_name, column_name, data_type
FROM all_tab_columns
WHERE owner = '%s'`, strings.ToUpper(dbName))
query := fmt.Sprintf(`SELECT c.table_name, c.column_name, c.data_type, cc.comments AS comment
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name
WHERE c.owner = '%s'`, strings.ReplaceAll(strings.ToUpper(dbName), "'", "''"))
data, _, err := d.Query(query)
if err != nil {
@@ -495,6 +497,7 @@ func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
Type: fmt.Sprintf("%v", row["DATA_TYPE"]),
Comment: fmt.Sprintf("%v", row["COMMENT"]),
}
cols = append(cols, col)
}

View File

@@ -523,11 +523,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
func (h *HighGoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY table_schema, table_name, ordinal_position`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
col_description(cls.oid, a.attnum) AS comment
FROM information_schema.columns c
LEFT JOIN pg_namespace n ON n.nspname = c.table_schema
LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name
LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY c.table_schema, c.table_name, c.ordinal_position`
data, _, err := h.Query(query)
if err != nil {
@@ -547,6 +555,7 @@ ORDER BY table_schema, table_name, ordinal_position`
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Comment: fmt.Sprintf("%v", row["comment"]),
}
cols = append(cols, col)
}

View File

@@ -406,6 +406,7 @@ func (i *IrisDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWith
TableName: tableName,
Name: name,
Type: buildIRISColumnType(row),
Comment: rowString(row, "DESCRIPTION", "description", "COMMENT", "comment"),
})
}
sort.SliceStable(cols, func(a, b int) bool {

View File

@@ -951,11 +951,19 @@ func splitKingbaseQualifiedTable(tableName string) (schema string, table string)
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
// dbName 在本项目语义里是“数据库”schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY table_schema, table_name, ordinal_position`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
col_description(cls.oid, a.attnum) AS comment
FROM information_schema.columns c
LEFT JOIN pg_namespace n ON n.nspname = c.table_schema
LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name
LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY c.table_schema, c.table_name, c.ordinal_position`
data, _, err := k.Query(query)
if err != nil {
@@ -974,6 +982,7 @@ func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Comment: fmt.Sprintf("%v", row["comment"]),
}
cols = append(cols, col)
}

View File

@@ -445,10 +445,10 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
if dbName == "" {
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
}
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''"))
data, _, err := m.Query(query)
if err != nil {
@@ -461,6 +461,7 @@ func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
Comment: fmt.Sprintf("%v", row["COLUMN_COMMENT"]),
}
cols = append(cols, col)
}

View File

@@ -1302,10 +1302,10 @@ func formatMySQLDateTime(t time.Time) string {
}
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
if dbName == "" {
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
}
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''"))
data, _, err := m.Query(query)
if err != nil {
@@ -1318,6 +1318,7 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
Comment: fmt.Sprintf("%v", row["COLUMN_COMMENT"]),
}
cols = append(cols, col)
}

View File

@@ -747,9 +747,11 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf(`SELECT table_name, column_name, data_type
FROM all_tab_columns
WHERE owner = '%s'`, strings.ToUpper(dbName))
query := fmt.Sprintf(`SELECT c.table_name, c.column_name, c.data_type, cc.comments AS comment
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name
WHERE c.owner = '%s'`, strings.ReplaceAll(strings.ToUpper(dbName), "'", "''"))
data, _, err := o.Query(query)
if err != nil {
@@ -762,6 +764,7 @@ func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
Type: fmt.Sprintf("%v", row["DATA_TYPE"]),
Comment: fmt.Sprintf("%v", row["COMMENT"]),
}
cols = append(cols, col)
}

View File

@@ -603,11 +603,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY table_schema, table_name, ordinal_position`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
col_description(cls.oid, a.attnum) AS comment
FROM information_schema.columns c
LEFT JOIN pg_namespace n ON n.nspname = c.table_schema
LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name
LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY c.table_schema, c.table_name, c.ordinal_position`
data, _, err := p.Query(query)
if err != nil {
@@ -627,6 +635,7 @@ ORDER BY table_schema, table_name, ordinal_position`
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Comment: fmt.Sprintf("%v", row["comment"]),
}
cols = append(cols, col)
}

View File

@@ -736,6 +736,7 @@ func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi
TableName: table,
Name: col.Name,
Type: col.Type,
Comment: col.Comment,
})
}
}

View File

@@ -355,13 +355,14 @@ ORDER BY c.column_id`,
func (s *SqlServerDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
safeDB := quoteBracket(dbName)
query := fmt.Sprintf(`
SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name, tp.name AS data_type
SELECT s.name AS schema_name, t.name AS table_name, c.name AS column_name, tp.name AS data_type, ep.value AS comment
FROM [%s].sys.columns c
JOIN [%s].sys.tables t ON c.object_id = t.object_id
JOIN [%s].sys.schemas s ON t.schema_id = s.schema_id
JOIN [%s].sys.types tp ON c.user_type_id = tp.user_type_id
LEFT JOIN [%s].sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
WHERE t.type = 'U'
ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB)
ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB, safeDB)
data, _, err := s.Query(query)
if err != nil {
@@ -379,6 +380,9 @@ ORDER BY s.name, t.name, c.column_id`, safeDB, safeDB, safeDB, safeDB)
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
}
if v, ok := row["comment"]; ok && v != nil {
col.Comment = fmt.Sprintf("%v", v)
}
cols = append(cols, col)
}
return cols, nil

View File

@@ -374,6 +374,7 @@ func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
TableName: table,
Name: col.Name,
Type: col.Type,
Comment: col.Comment,
})
}
}

View File

@@ -522,11 +522,19 @@ ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
func (v *VastbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY table_schema, table_name, ordinal_position`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
col_description(cls.oid, a.attnum) AS comment
FROM information_schema.columns c
LEFT JOIN pg_namespace n ON n.nspname = c.table_schema
LEFT JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name
LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
AND c.table_schema NOT LIKE 'pg|_%' ESCAPE '|'
ORDER BY c.table_schema, c.table_name, c.ordinal_position`
data, _, err := v.Query(query)
if err != nil {
@@ -546,6 +554,7 @@ ORDER BY table_schema, table_name, ordinal_position`
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Comment: fmt.Sprintf("%v", row["comment"]),
}
cols = append(cols, col)
}