🐛 fix(query-editor): 修复 MySQL 关键字需美化后才着色

- 根据当前连接方言为 MySQL 系查询启用 Monaco MySQL 语法
- 将补全、悬浮、斜杠命令和代码片段同步注册到 MySQL 语言
- 增加方言映射和首屏着色回归测试

Fixes #612
This commit is contained in:
Syngnat
2026-07-18 10:32:51 +08:00
parent 1ee8f7f980
commit 07a4fe1fc9
4 changed files with 96 additions and 18 deletions

View File

@@ -170,6 +170,7 @@ const autoFetchState = vi.hoisted(() => ({
const monacoEditorMockState = vi.hoisted(() => ({
deferOnMount: false,
latestProps: null as any,
}));
const defaultEditorContributionResolver = (state: {
@@ -199,7 +200,9 @@ const editorState = vi.hoisted(() => {
position: { lineNumber: 1, column: 1 },
selection: null as any,
providers: [] as any[],
providerLanguages: [] as string[],
hoverProviders: [] as any[],
hoverProviderLanguages: [] as string[],
contentChangeListeners: [] as Array<() => void>,
cursorPositionListeners: [] as Array<(event: any) => void>,
modelContentListeners: [] as Array<(event: any) => void>,
@@ -365,7 +368,9 @@ vi.mock('../utils/autoFetchVisibility', () => ({
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onChange, onMount }: any) => {
default: (props: any) => {
const { defaultValue, onChange, onMount } = props;
monacoEditorMockState.latestProps = props;
React.useEffect(() => {
editorState.value = String(defaultValue || '');
editorState.latestOnChange = onChange;
@@ -376,11 +381,13 @@ vi.mock('@monaco-editor/react', () => ({
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
registerCompletionItemProvider: vi.fn((_language: string, provider: any) => {
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
editorState.providerLanguages.push(language);
editorState.providers.push(provider);
return { dispose: vi.fn() };
}),
registerHoverProvider: vi.fn((_language: string, provider: any) => {
registerHoverProvider: vi.fn((language: string, provider: any) => {
editorState.hoverProviderLanguages.push(language);
editorState.hoverProviders.push(provider);
return { dispose: vi.fn() };
}),
@@ -865,9 +872,12 @@ describe('QueryEditor external SQL save', () => {
editorState.value = '';
editorState.position = { lineNumber: 1, column: 1 };
editorState.selection = null;
monacoEditorMockState.latestProps = null;
editorState.domNode.style.cursor = '';
editorState.providers = [];
editorState.providerLanguages = [];
editorState.hoverProviders = [];
editorState.hoverProviderLanguages = [];
editorState.contentChangeListeners = [];
editorState.cursorPositionListeners = [];
editorState.modelContentListeners = [];
@@ -2369,9 +2379,34 @@ describe('QueryEditor external SQL save', () => {
const completionState = (globalThis as any).__gonaviSqlCompletionState;
expect(editorState.hoverProviders).toHaveLength(1);
expect(editorState.providers).toHaveLength(3);
expect(completionState.disposables).toHaveLength(4);
expect(editorState.hoverProviderLanguages).toEqual(['sql', 'mysql']);
expect(editorState.providerLanguages).toEqual(['sql', 'mysql', 'sql', 'mysql', 'sql', 'mysql']);
expect(editorState.hoverProviders).toHaveLength(2);
expect(editorState.providers).toHaveLength(6);
expect(completionState.disposables).toHaveLength(8);
await act(async () => {
renderer.unmount();
});
});
it.each([
['mysql', 'mysql'],
['mariadb', 'mysql'],
['postgres', 'sql'],
])('uses the %s connection grammar before formatting SQL', async (dbType, expectedLanguage) => {
storeState.connections[0].config.type = dbType;
const initialSql = "update finan_ set openid = 'ol_' where pay_status = '0' limit 50";
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: initialSql })} />);
});
expect(monacoEditorMockState.latestProps).toMatchObject({
defaultValue: initialSql,
language: expectedLanguage,
});
await act(async () => {
renderer.unmount();
@@ -6122,7 +6157,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(editorState.hoverProviders).toHaveLength(1);
expect(editorState.hoverProviders).toHaveLength(2);
const hover = editorState.hoverProviders[0].provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 18 },

View File

@@ -160,6 +160,7 @@ import {
resolveOracleLikeExecutionSchemaName,
resolveOracleLikeLookupSchemaCandidates,
resolveQueryEditorFormatterLanguage,
resolveQueryEditorMonacoLanguage,
resolveQueryEditorHoverTarget,
resolveQueryEditorNavigationDecorations,
resolveQueryEditorNavigationTarget,
@@ -886,7 +887,8 @@ const resolveQueryEditorAiConnectionHost = (connection: any): string => {
// HMR 重载时释放旧注册避免补全和 hover 内容重复
const _g = globalThis as any;
const SQL_COMPLETION_PROVIDER_VERSION = '20260715-oracle-view-synonym-v1';
const SQL_COMPLETION_PROVIDER_VERSION = '20260718-mysql-language-v1';
const QUERY_EDITOR_MONACO_LANGUAGE_IDS = ['sql', 'mysql'] as const;
if (!_g.__gonaviSqlCompletionState) {
_g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] };
}
@@ -1379,6 +1381,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
),
[connections, currentConnectionId],
);
const queryEditorMonacoLanguage = useMemo(
() => resolveQueryEditorMonacoLanguage(
connections.find(connection => connection.id === currentConnectionId),
),
[connections, currentConnectionId],
);
const addSqlLog = useStore(state => state.addSqlLog);
const sqlLogCount = useStore(state => state.sqlLogs.length);
@@ -4860,7 +4868,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
_g.__gonaviSqlCompletionState.version = SQL_COMPLETION_PROVIDER_VERSION;
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
sqlCompletionDisposables.length = 0;
sqlCompletionDisposables.push(monaco.languages.registerHoverProvider('sql', {
const registerQueryEditorHoverProvider = (provider: any) => {
QUERY_EDITOR_MONACO_LANGUAGE_IDS.forEach((languageId) => {
sqlCompletionDisposables.push(monaco.languages.registerHoverProvider(languageId, provider));
});
};
const registerQueryEditorCompletionProvider = (provider: any) => {
QUERY_EDITOR_MONACO_LANGUAGE_IDS.forEach((languageId) => {
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider(languageId, provider));
});
};
registerQueryEditorHoverProvider({
provideHover: (model: any, position: any) => {
const normalizedPosition = normalizeEditorPosition(position);
if (!normalizedPosition) {
@@ -4896,8 +4914,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
contents: [{ value: buildQueryEditorHoverMarkdown(hoverTarget) }],
};
},
}));
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
});
registerQueryEditorCompletionProvider({
triggerCharacters: ['.'],
provideCompletionItems: async (model: any, position: any, _context?: any, token?: { isCancellationRequested?: boolean }) => {
if (isSqlCompletionRequestCancelled(token)) {
@@ -5792,8 +5810,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
];
return { suggestions };
}
}));
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
});
registerQueryEditorCompletionProvider({
triggerCharacters: ['/'],
provideCompletionItems: (model: any, position: any) => {
const lineContent = model.getLineContent(position.lineNumber);
@@ -5820,11 +5838,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
})),
};
},
}));
});
// SQL snippet completion provider
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
registerQueryEditorCompletionProvider({
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const prefix = word.word.toLowerCase();
@@ -5856,7 +5874,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
})),
};
},
}));
});
} // end sqlCompletionRegistered guard
@@ -8710,7 +8728,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
<Editor
height="100%"
gonaviTypography="code"
defaultLanguage="sql"
language={queryEditorMonacoLanguage}
theme={darkMode ? "transparent-dark" : "transparent-light"}
defaultValue={query}
onChange={(val) => {

View File

@@ -7,11 +7,25 @@ import {
resolveOracleLikeDefaultSchemaName,
resolveOracleLikeExecutionSchemaName,
resolveOracleLikeLookupSchemaCandidates,
resolveQueryEditorMonacoLanguage,
resolveQueryEditorNavigationTarget,
selectUnqualifiedCompletionSynonyms,
shouldHandleQueryEditorRunShortcutFallback,
} from './QueryEditorHelpers';
describe('QueryEditor Monaco SQL grammar', () => {
it.each([
[{ config: { type: 'mysql' } }, 'mysql'],
[{ config: { type: 'mariadb' } }, 'mysql'],
[{ config: { type: 'custom', driver: 'greatdb' } }, 'mysql'],
[{ config: { type: 'oceanbase', oceanBaseProtocol: 'mysql' } }, 'mysql'],
[{ config: { type: 'oceanbase', oceanBaseProtocol: 'oracle' } }, 'sql'],
[{ config: { type: 'postgres' } }, 'sql'],
])('maps connection row %# to the expected Monaco grammar', (connection, expectedLanguage) => {
expect(resolveQueryEditorMonacoLanguage(connection)).toBe(expectedLanguage);
});
});
describe('QueryEditor run shortcut routing', () => {
it('reserves editor-originated shortcuts for Monaco and keeps document targets as a fallback', () => {
const editorTarget = {} as Node;

View File

@@ -2,7 +2,7 @@ import type { SqlLanguage } from 'sql-formatter';
import type { TabData, ColumnDefinition, IndexDefinition } from '../../types';
import { DBGetColumns, DBGetIndexes, DBQuery } from '../../../wailsjs/go/app/App';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect } from '../../utils/sqlDialect';
import { isMysqlFamilyDialect, isOracleLikeDialect, resolveSqlDialect } from '../../utils/sqlDialect';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../../utils/queryResultTable';
import { quoteIdentPart } from '../../utils/sql';
import { splitSidebarQualifiedName } from '../../utils/sidebarLocate';
@@ -572,6 +572,17 @@ export const normalizeMetadataDialect = (conn: any): string => {
return String(dialect || '').toLowerCase();
};
export type QueryEditorMonacoLanguage = 'sql' | 'mysql';
export const resolveQueryEditorMonacoLanguage = (conn: any): QueryEditorMonacoLanguage => {
const dialect = resolveSqlDialect(
String(conn?.config?.type || ''),
String(conn?.config?.driver || ''),
{ oceanBaseProtocol: conn?.config?.oceanBaseProtocol },
);
return isMysqlFamilyDialect(dialect) ? 'mysql' : 'sql';
};
export const resolveQueryEditorFormatterLanguage = (conn: any): SqlLanguage => {
const dialect = normalizeMetadataDialect(conn);
switch (dialect) {