mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 17:31:27 +08:00
✨ feat(query-editor): 增强 SQL 编辑器执行与历史体验
- 支持仅执行选中 SQL、光标所在语句和增量新增语句 - 持久化查询草稿,避免重启后丢失历史 SQL - 在表字段提示中展示注释信息 - 修复清空默认 SQL 后被自动回填的问题 Refs #483
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 值。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -736,6 +736,7 @@ func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWi
|
||||
TableName: table,
|
||||
Name: col.Name,
|
||||
Type: col.Type,
|
||||
Comment: col.Comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -374,6 +374,7 @@ func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
|
||||
TableName: table,
|
||||
Name: col.Name,
|
||||
Type: col.Type,
|
||||
Comment: col.Comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user