From 5c281b1653d245a8eb2b2389ec75f813a69667da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 6 Aug 2026 00:55:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=94=AF=E6=8C=81=20SELECT=20*?= =?UTF-8?q?=20=E5=B1=95=E5=BC=80=E4=B8=BA=E5=AD=97=E6=AE=B5=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(editor): 支持将 SELECT * 展开为字段列表 - 新增右键菜单及 `Mod+Shift+X` 快捷键,可展开 `*` 和 `别名.*` - 基于语义分析识别单表来源、别名及原始引用符,并按数据库方言处理字段名转义 - 优先读取本地或远程表字段元数据,单表纯星号查询可回退使用执行结果字段 - 增加字段加载失败提示及多语言文案 - 补充星号展开、结果匹配和快捷键注册测试 * fix(editor): selectStar 回退加词边界检查并记录加载失败 --------- Co-authored-by: t8y2 Co-authored-by: skyler <1156263951@qq.com> --- apps/desktop/node_modules | 1 + .../src/components/editor/QueryEditor.vue | 183 +++++++++++++++++- .../src/components/layout/ContentArea.vue | 4 + apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../__tests__/editor/shortcutRegistry.spec.ts | 8 + .../lib/__tests__/sql/semantic/model.spec.ts | 42 +++- .../sql/sqlCompletion.context.spec.ts | 116 ++++++++++- .../src/lib/editor/shortcutRegistry.ts | 7 + .../src/lib/sql/semantic/completion.ts | 57 +++++- apps/desktop/src/lib/sql/semantic/model.ts | 3 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 45 ++++- 18 files changed, 473 insertions(+), 17 deletions(-) create mode 120000 apps/desktop/node_modules diff --git a/apps/desktop/node_modules b/apps/desktop/node_modules new file mode 120000 index 0000000000..82b792adba --- /dev/null +++ b/apps/desktop/node_modules @@ -0,0 +1 @@ +/Users/skyler/VsCodeProjects/dbx-workspace/dbx/apps/desktop/node_modules \ No newline at end of file diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 0a6b080b46..15ba9545bc 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -32,6 +32,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; import { useToast } from "@/composables/useToast"; import { + buildSelectStarExpansion, buildSqlCompletionItemsFromContext, getSqlFunctionSignatureHelp, getSqlCompletionContext, @@ -39,11 +40,12 @@ import { isSqlCompletionSuppressedContext, isSqlLikeCompletionStatement, recordCompletionSelection, + selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion, shouldChainSqlCompletionAfterAccept, extractCteDefinitions, } from "@/lib/sql/sqlCompletion"; -import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"; +import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource } from "@/lib/sql/semantic/completion"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references"; import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion"; @@ -130,7 +132,7 @@ import { import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion"; -import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo, SqlReferenceAnalysis, SqlServerCompletionContext, SqlTableReference, SqlTextSpan } from "@/types/database"; const props = defineProps<{ @@ -149,6 +151,10 @@ const props = defineProps<{ compressRequestId?: number; executionError?: string; executionErrorSql?: string; + resultColumns?: string[]; + resultSourceStatement?: string; + resultSourceFrom?: number; + resultSourceTo?: number; readOnly?: boolean; autoFocus?: boolean; forceWordWrap?: boolean; @@ -314,6 +320,18 @@ const selectedSql = ref(""); const executableSql = ref(""); const contextObjectTarget = ref(null); +interface SelectStarExpansionTarget { + from: number; + to: number; + reference: SqlCompletionReferencedTable; + context: SqlCompletionContext; + qualifierSql?: string; + statementSql: string; + allowResultColumnsFallback: boolean; +} + +const selectStarExpansionTarget = ref(null); + const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0); const canCopySelectedSql = computed(() => selectedSql.value.length > 0); const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0); @@ -792,14 +810,91 @@ function closePicker() { view.value?.focus(); } -function syncContextMenuState(currentView: EditorViewType) { +function syncContextMenuState(currentView: EditorViewType, starPosition?: number) { selectedSql.value = selectedSqlFromView(currentView); executableSql.value = executableSqlFromView(currentView); + selectStarExpansionTarget.value = selectStarExpansionTargetForView(currentView, starPosition); +} + +function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null { + if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null; + + const sql = currentView.state.doc.toString(); + const selection = currentView.state.selection.main; + let cursor: number; + if (position != null) { + if (sql[position] === "*") { + cursor = position + 1; + } else if (sql[position - 1] === "*") { + cursor = position; + } else { + return null; + } + } else if (!selection.empty) { + if (currentView.state.sliceDoc(selection.from, selection.to) !== "*") return null; + cursor = selection.to; + } else if (sql[selection.head] === "*") { + cursor = selection.head + 1; + } else if (sql[selection.head - 1] === "*") { + cursor = selection.head; + } else { + return null; + } + + const model = buildSqlSemanticModel(sql, cursor, sqlCompletionDialectOptions()); + const intent = model.cursorIntent; + if (intent.kind !== "star" || intent.confidence !== "high" || intent.replacementRange.end - intent.replacementRange.start !== 1 || sql.slice(intent.replacementRange.start, intent.replacementRange.end) !== "*") return null; + if (position == null && !selection.empty && (selection.from !== intent.replacementRange.start || selection.to !== intent.replacementRange.end)) return null; + + const starToken = model.tokens.find((token) => token.span.start === intent.replacementRange.start && token.span.end === intent.replacementRange.end && token.text === "*"); + if (!starToken) return null; + let isSelectProjection = false; + for (let index = model.tokens.length - 1; index >= 0; index -= 1) { + const token = model.tokens[index]; + if (!token || token.span.end > starToken.span.start || token.depth !== starToken.depth || token.kind !== "word") continue; + if (token.normalized === "from") return null; + if (token.normalized === "select") { + isSelectProjection = true; + break; + } + } + if (!isSelectProjection) return null; + + const source = sqlSemanticSelectStarTableSource(model); + if (!source) return null; + + const sourceTarget = queryTableCandidateAtSqlPosition({ + connectionId: props.connectionId, + database: props.database, + schema: props.schema, + databaseType: props.databaseType, + sql, + position: source.qualifiedName?.span.start ?? source.sourceSpan.start, + }); + const reference: SqlCompletionReferencedTable = { + name: sourceTarget?.tableName ?? source.metadataTarget?.table ?? source.name, + database: sourceTarget?.database ?? source.metadataTarget?.database, + schema: sourceTarget?.schema ?? source.metadataTarget?.schema, + alias: source.alias, + }; + const legacyContext = getSqlCompletionContext(sql, cursor, sqlCompletionDialectOptions()); + const context = sqlCompletionContextFromSemantic(model, legacyContext); + if (context.statementKind !== "select" || !context.onStar) return null; + + return { + from: intent.replacementRange.start, + to: intent.replacementRange.end, + reference, + context: { ...context, referencedTables: [reference] }, + qualifierSql: sqlSemanticSelectStarQualifierSql(model), + statementSql: model.statement.text, + allowResultColumnsFallback: model.rowSources.length === 1 && sqlSemanticSelectStarIsOnlyProjection(model), + }; } function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) { - syncContextMenuState(currentView); const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }); + syncContextMenuState(currentView, pos ?? undefined); if (pos == null) { contextObjectTarget.value = null; return; @@ -1327,6 +1422,9 @@ function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: numb const contextMenuItems = computed(() => { const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); + // The menu closes before running its action, so retain this right-click's + // resolved target instead of reading state after the close handler runs. + const starExpansionTarget = selectStarExpansionTarget.value; return [ ...(props.hideExecutionControls ? [] @@ -1357,6 +1455,13 @@ const contextMenuItems = computed(() => { }, ]), ...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem), + { + label: t("editor.contextMenu.expandSelectStar"), + action: () => void expandSelectStar(starExpansionTarget), + disabled: !starExpansionTarget, + icon: Table2, + shortcut: shortcuts.expandSelectStar, + }, { label: "", separator: true }, { label: t("editor.contextMenu.copySelection"), @@ -1445,6 +1550,12 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") void formatCurrentSql(); return true; }), + ...binding(shortcuts.expandSelectStar, (currentView) => { + const target = selectStarExpansionTargetForView(currentView); + if (!target) return false; + void expandSelectStar(target); + return true; + }), ...binding(shortcuts.indentMore, (view) => codeMirrorIndentMore?.(view) ?? false), ...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false), ...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false), @@ -1788,19 +1899,79 @@ async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promi return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null; } -async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }): Promise { +async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }, reference?: Pick): Promise { if (isVirtualCompletionTableReference(table)) return false; const cacheKey = completionCacheKey(table); if (cachedColumnsByTable.has(cacheKey)) return true; if (!props.connectionId || props.database == null) return false; const target = completionMetadataTarget(table); if (!target) return false; - const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog); + const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, table.name, target.schema, target.catalog, completionColumnRequestContext(reference)); + if (localColumns.length > 0) { + cachedColumnsByTable.set(cacheKey, localColumns); + loadedColumnsByTable.add(cacheKey.toLowerCase()); + return true; + } + const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog, reference); cachedColumnsByTable.set(cacheKey, columns); loadedColumnsByTable.add(cacheKey.toLowerCase()); return true; } +function resultColumnsForSelectStar(target: SelectStarExpansionTarget, sql: string): SqlCompletionColumn[] { + if ( + !target.allowResultColumnsFallback || + !selectStarResultColumnsMatch({ + currentSql: sql, + targetFrom: target.from, + targetTo: target.to, + statementSql: target.statementSql, + sourceStatement: props.resultSourceStatement, + sourceFrom: props.resultSourceFrom, + sourceTo: props.resultSourceTo, + }) + ) + return []; + return (props.resultColumns ?? []) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => ({ name, table: target.reference.name, schema: target.reference.schema })); +} + +async function expandSelectStar(target = selectStarExpansionTarget.value) { + const currentView = view.value; + if (!currentView || props.readOnly) return; + if (!target) return; + + const originalDocument = currentView.state.doc.toString(); + try { + await ensureColumnsForTable(target.reference, target.reference); + } catch (error) { + console.warn("expandSelectStar: failed to load columns", error); + } + + if (view.value !== currentView || currentView.state.doc.toString() !== originalDocument || currentView.state.sliceDoc(target.from, target.to) !== "*") return; + const columns = cachedColumnsByTable.get(completionCacheKey(target.reference)); + const expansionColumns = columns?.length ? columns : resultColumnsForSelectStar(target, originalDocument); + if (expansionColumns.length === 0) { + toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000); + return; + } + const expansion = buildSelectStarExpansion(target.context, new Map([[completionCacheKey(target.reference), expansionColumns]]), props.dialect, target.qualifierSql, props.databaseType); + if (!expansion) { + toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000); + return; + } + + currentView.dispatch({ + changes: { from: target.from, to: target.to, insert: expansion }, + selection: { anchor: target.from + expansion.length }, + scrollIntoView: true, + userEvent: "input.expandSelectStar", + }); + currentView.focus(); +} + function isMissingTableMetadataError(error: unknown) { const message = String(error instanceof Error ? error.message : error).toLowerCase(); return message.includes("42s02") || message.includes("1146") || message.includes("doesn't exist") || message.includes("does not exist") || message.includes("unknown table"); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index c10e4d14bb..9d127fc946 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -972,6 +972,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :compress-request-id="compressSqlRequest?.tabId === activeTab.id ? compressSqlRequest.id : undefined" :execution-error="activeQueryError" :execution-error-sql="activeTab.lastExecutedSql" + :result-columns="activeTab.result?.columns" + :result-source-statement="activeTab.result?.sourceStatement" + :result-source-from="activeTab.result?.sourceFrom" + :result-source-to="activeTab.result?.sourceTo" :statement-execution-markers="activeStatementExecutionMarkers" :initial-viewport="activeTab.editorViewport" :initial-selection="activeTab.editorSelection" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index df4f6d3500..7089c51856 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -947,6 +947,8 @@ export default { uppercaseSelection: "Convert to uppercase", lowercaseSelection: "Convert to lowercase", delimitedList: "Convert to delimited list", + expandSelectStar: "Expand * to columns", + expandSelectStarUnavailable: "Unable to load columns for this table", findReplace: "Find/Replace", deleteEmptyLines: "Delete empty lines", selectAll: "Select all", @@ -5256,6 +5258,7 @@ export default { shortcutSaveSql: "Save SQL", shortcutAcceptCompletion: "Accept completion", shortcutFormatSql: "Format SQL", + shortcutExpandSelectStar: "Expand * to columns", shortcutToggleLineComment: "Toggle line comment", shortcutIndentMore: "Indent more", shortcutIndentLess: "Indent less", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 663bddb574..837409e296 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -927,6 +927,8 @@ export default withEnglishFallback({ lowercaseSelection: "Convertir a minusculas", selectAll: "Seleccionar todo", delimitedList: "Convertir a lista delimitada", + expandSelectStar: "Expandir * a columnas", + expandSelectStarUnavailable: "No se pudieron cargar las columnas de esta tabla", findReplace: "Buscar/Reemplazar", deleteEmptyLines: "Eliminar líneas vacías", }, @@ -4991,6 +4993,7 @@ export default withEnglishFallback({ shortcutSaveSql: "Guardar SQL", shortcutAcceptCompletion: "Aceptar completado", shortcutFormatSql: "Formatear SQL", + shortcutExpandSelectStar: "Expandir * a columnas", shortcutToggleLineComment: "Alternar comentario de línea", shortcutIndentMore: "Aumentar sangría", shortcutIndentLess: "Reducir sangría", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0411bad657..a4e915895d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -925,6 +925,8 @@ export default withEnglishFallback({ lowercaseSelection: "Converti in minuscolo", selectAll: "Seleziona tutto", delimitedList: "Converti in lista delimitata", + expandSelectStar: "Espandi * in colonne", + expandSelectStarUnavailable: "Impossibile caricare le colonne di questa tabella", findReplace: "Trova/Sostituisci", deleteEmptyLines: "Elimina righe vuote", }, @@ -4991,6 +4993,7 @@ export default withEnglishFallback({ shortcutSaveSql: "Salva SQL", shortcutAcceptCompletion: "Accetta completamento", shortcutFormatSql: "Formatta SQL", + shortcutExpandSelectStar: "Espandi * in colonne", shortcutToggleLineComment: "Attiva/disattiva commento di riga", shortcutIndentMore: "Aumenta rientro", shortcutIndentLess: "Riduci rientro", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ad25c5cbb8..de0e8fdfbb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -945,6 +945,8 @@ export default withEnglishFallback({ lowercaseSelection: "小文字に変換", selectAll: "すべて選択", delimitedList: "区切り文字付きリストに変換", + expandSelectStar: "* を列に展開", + expandSelectStarUnavailable: "このテーブルの列を読み込めません", findReplace: "検索/置換", deleteEmptyLines: "空行を削除", }, @@ -5013,6 +5015,7 @@ export default withEnglishFallback({ shortcutSaveSql: "SQLを保存", shortcutAcceptCompletion: "補完を受け入れる", shortcutFormatSql: "SQLをフォーマット", + shortcutExpandSelectStar: "* を列に展開", shortcutToggleLineComment: "行コメントを切り替え", shortcutIndentMore: "インデントを増やす", shortcutIndentLess: "インデントを減らす", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 81a92fc7ad..bf6f1e38a7 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -867,6 +867,8 @@ export default withEnglishFallback({ uppercaseSelection: "대문자로 변환", lowercaseSelection: "소문자로 변환", delimitedList: "구분자 목록으로 변환", + expandSelectStar: "*을(를) 열로 확장", + expandSelectStarUnavailable: "이 테이블의 열을 불러올 수 없습니다", findReplace: "찾기/바꾸기", deleteEmptyLines: "빈 줄 삭제", selectAll: "모두 선택", @@ -4784,6 +4786,7 @@ export default withEnglishFallback({ shortcutSaveSql: "SQL 저장", shortcutAcceptCompletion: "완성 수락", shortcutFormatSql: "SQL 서식", + shortcutExpandSelectStar: "*을(를) 열로 확장", shortcutToggleLineComment: "줄 주석 전환", shortcutIndentMore: "들여쓰기 늘리기", shortcutIndentLess: "들여쓰기 줄이기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1564950734..26f998599e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -926,6 +926,8 @@ export default withEnglishFallback({ lowercaseSelection: "Converter para minúsculas", selectAll: "Selecionar tudo", delimitedList: "Converter para lista delimitada", + expandSelectStar: "Expandir * em colunas", + expandSelectStarUnavailable: "Não foi possível carregar as colunas desta tabela", findReplace: "Localizar/substituir", deleteEmptyLines: "Excluir linhas vazias", }, @@ -4993,6 +4995,7 @@ export default withEnglishFallback({ shortcutSaveSql: "Salvar SQL", shortcutAcceptCompletion: "Aceitar autocompletar", shortcutFormatSql: "Formatar SQL", + shortcutExpandSelectStar: "Expandir * em colunas", shortcutToggleLineComment: "Alternar comentário de linha", shortcutIndentMore: "Aumentar recuo", shortcutIndentLess: "Reduzir recuo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 95a6251036..5373d4929b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -948,6 +948,8 @@ export default withEnglishFallback({ uppercaseSelection: "转为大写", lowercaseSelection: "转为小写", delimitedList: "转换为带分隔符的列表", + expandSelectStar: "将 * 展开为字段", + expandSelectStarUnavailable: "无法读取该表的字段信息", findReplace: "查找/替换", deleteEmptyLines: "删除空行", selectAll: "全选", @@ -5253,6 +5255,7 @@ export default withEnglishFallback({ shortcutSaveSql: "保存 SQL", shortcutAcceptCompletion: "接受补全", shortcutFormatSql: "格式化 SQL", + shortcutExpandSelectStar: "将 * 展开为字段", shortcutToggleLineComment: "切换行注释", shortcutIndentMore: "增加缩进", shortcutIndentLess: "减少缩进", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a0cf9bfce9..7ee155a4b9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -925,6 +925,8 @@ export default withEnglishFallback({ lowercaseSelection: "轉為小寫", selectAll: "全選", delimitedList: "轉換為帶分隔符的清單", + expandSelectStar: "將 * 展開為欄位", + expandSelectStarUnavailable: "無法讀取此資料表的欄位資訊", findReplace: "查找/取代", deleteEmptyLines: "刪除空行", }, @@ -4443,6 +4445,7 @@ export default withEnglishFallback({ shortcutSaveSql: "儲存 SQL", shortcutAcceptCompletion: "接受補全", shortcutFormatSql: "格式化 SQL", + shortcutExpandSelectStar: "將 * 展開為欄位", shortcutToggleLineComment: "切換行註解", shortcutIndentMore: "增加縮排", shortcutIndentLess: "減少縮排", diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index d04f3c40a8..c4d99ffc63 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -42,6 +42,14 @@ describe("shortcutRegistry editor actions", () => { expect(findShortcutConflict("executeSqlInNewResultTab", DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); }); + it("registers a conflict-free shortcut for expanding SELECT stars", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "expandSelectStar"); + + expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Mod+Shift+X" }); + expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.expandSelectStar)).toBe("Mod-Shift-x"); + expect(findShortcutConflict("expandSelectStar", DEFAULT_SHORTCUT_SETTINGS.expandSelectStar, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); + }); + it("resolves the close-other-tabs default per platform and heals cross-platform synced defaults", () => { // 本测试环境(darwin):默认应为 macOS 组合 expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut()); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts index 8d025f24df..56e67d555c 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns } from "@/lib/sql/semantic/completion"; +import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource } from "@/lib/sql/semantic/completion"; import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sql/semantic/fixtures"; import { buildSqlSemanticModel, sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model"; @@ -29,6 +29,46 @@ describe("sqlSemanticModel baseline fixtures", () => { }); } + it("does not treat SELECT as the qualifier of an unqualified star", () => { + const { sql, cursor } = sqlFixtureCursor("select *| from apis as ap"); + const model = buildSqlSemanticModel(sql, cursor); + + expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "star", qualifierParts: [] })); + }); + + it("retains the qualifier of a qualified star", () => { + const { sql, cursor } = sqlFixtureCursor("select ap.*| from apis as ap"); + const model = buildSqlSemanticModel(sql, cursor); + + expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "star", qualifierParts: ["ap"], targetSourceId: model.rowSources[0]?.id })); + }); + + it("does not resolve an unqualified star to the only physical table among multiple row sources", () => { + const { sql, cursor } = sqlFixtureCursor("select *| from users u join (select 1 as x) s on 1 = 1"); + const model = buildSqlSemanticModel(sql, cursor); + + expect(model.rowSources.map((source) => source.kind)).toEqual(["table", "subquery"]); + expect(sqlSemanticSelectStarTableSource(model)).toBeUndefined(); + }); + + it.each([ + ["select *| from apis", true], + ["select distinct *| from apis", true], + ["select ap.*| from apis ap", true], + ["select *, 1 as extra| from apis", false], + ["select ap.*, 1 as extra| from apis ap", false], + ] as const)("detects whether the star is the only projection in %s", (markedSql, expected) => { + const { sql, cursor } = sqlFixtureCursor(markedSql); + + expect(sqlSemanticSelectStarIsOnlyProjection(buildSqlSemanticModel(sql, cursor))).toBe(expected); + }); + + it("retains the original quoting of a star qualifier", () => { + const { sql, cursor } = sqlFixtureCursor('select "Order Alias".*| from orders as "Order Alias"'); + + expect(sqlSemanticSelectStarQualifierSql(buildSqlSemanticModel(sql, cursor, { databaseType: "postgres", dialect: "postgres" }))).toBe('"Order Alias"'); + }); + it("does not mix row sources from inactive statements", () => { const { sql, cursor } = sqlFixtureCursor("select * from users u; select * from orders o where o.|"); const model = buildSqlSemanticModel(sql, cursor); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index fff67c2abc..93a6672363 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { buildSqlCompletionItems, getSqlCompletionContext, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion"; +import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion"; +import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"; +import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; describe("sqlCompletion keyword snippets", () => { it("auto-opens and suggests SELECT when typing sel", () => { @@ -14,6 +16,118 @@ describe("sqlCompletion keyword snippets", () => { }); }); +describe("SELECT star expansion", () => { + it("reuses completion column ordering for an unqualified star", () => { + const sql = "SELECT * FROM apis"; + const context = getSqlCompletionContext(sql, "SELECT *".length); + + expect( + buildSelectStarExpansion( + context, + new Map([ + [ + "apis", + [ + { name: "id", table: "apis" }, + { name: "created_at", table: "apis" }, + { name: "method", table: "apis" }, + ], + ], + ]), + ), + ).toBe("id, created_at, method"); + }); + + it("preserves an alias while replacing only the star", () => { + const sql = "SELECT ap.* FROM apis AS ap"; + const cursor = "SELECT ap.*".length; + const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor)); + + expect( + buildSelectStarExpansion( + context, + new Map([ + [ + "apis", + [ + { name: "id", table: "apis" }, + { name: "created_at", table: "apis" }, + ], + ], + ]), + ), + ).toBe("id, ap.created_at"); + }); + + it.each([ + ["postgres", "postgres", '"Order Alias"', '"created at"'], + ["mysql", "mysql", "`Order Alias`", "`created at`"], + ["sqlserver", "sqlserver", "[Order Alias]", "[created at]"], + ["oracle", "mysql", '"Order Alias"', '"created at"'], + ] as const)("preserves a quoted %s alias for every expanded column", (databaseType, dialect, qualifierSql, quotedColumn) => { + const sql = `SELECT ${qualifierSql}.* FROM orders AS ${qualifierSql}`; + const cursor = sql.indexOf("*") + 1; + const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor, { databaseType, dialect }), getSqlCompletionContext(sql, cursor, { databaseType, dialect })); + + expect( + buildSelectStarExpansion( + context, + new Map([ + [ + "orders", + [ + { name: "id", table: "orders" }, + { name: "created at", table: "orders" }, + ], + ], + ]), + dialect, + qualifierSql, + databaseType, + ), + ).toBe(`id, ${qualifierSql}.${quotedColumn}`); + }); + + it("expands an unqualified star from result columns when the table has an alias", () => { + const sql = "select *\nfrom apis as ap\nlimit 100;"; + const cursor = "select *".length; + const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor)); + + expect( + buildSelectStarExpansion( + context, + new Map([ + [ + "apis", + [ + { name: "id", table: "apis" }, + { name: "created_at", table: "apis" }, + { name: "updated_at", table: "apis" }, + { name: "deleted_at", table: "apis" }, + { name: "method", table: "apis" }, + ], + ], + ]), + ), + ).toBe("id, created_at, updated_at, deleted_at, method"); + }); + + it("accepts result columns only when their source still contains the target star", () => { + const currentSql = "select * from apis;\nselect * from users;"; + const sourceStatement = "select * from users"; + const sourceFrom = currentSql.lastIndexOf("select"); + const targetFrom = currentSql.lastIndexOf("*"); + + expect(selectStarResultColumnsMatch({ currentSql, targetFrom, targetTo: targetFrom + 1, statementSql: sourceStatement, sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(true); + expect(selectStarResultColumnsMatch({ currentSql, targetFrom: currentSql.indexOf("*"), targetTo: currentSql.indexOf("*") + 1, statementSql: "select * from apis", sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(false); + }); + + it("rejects stale and incomplete result source metadata", () => { + expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from apis" })).toBe(false); + expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from users", sourceFrom: 0 })).toBe(false); + }); +}); + describe("sqlCompletion database functions", () => { it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => { const sql = "SELECT tostart"; diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 24b8327997..a40eab167f 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -4,6 +4,7 @@ export type ShortcutActionId = | "executeSql" | "executeSqlInNewResultTab" | "formatSql" + | "expandSelectStar" | "toggleLineComment" | "saveSql" | "acceptCompletion" @@ -103,6 +104,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "editor", defaultShortcut: "Shift+Mod+F", }, + { + id: "expandSelectStar", + labelKey: "settings.shortcutExpandSelectStar", + scope: "editor", + defaultShortcut: "Mod+Shift+X", + }, { id: "toggleLineComment", labelKey: "settings.shortcutToggleLineComment", diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 1d969dddf5..3b0fc9453f 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -1,6 +1,6 @@ import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion"; import { SQL_SEMANTIC_DIALECTS } from "@/lib/sql/semantic/dialect"; -import type { SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types"; +import type { SqlSemanticModel, SqlSemanticRowSource, SqlSemanticToken } from "@/lib/sql/semantic/types"; export type SqlSemanticCompletionScopeKind = "keyword" | "table" | "schema" | "catalog" | "routine" | "columns" | "local"; @@ -13,6 +13,61 @@ export interface SqlSemanticCompletionScope { fallbackReason?: string; } +function isSemanticIdentifier(token: SqlSemanticToken | undefined): boolean { + return token?.kind === "word" || token?.kind === "quoted_identifier"; +} + +function selectStarToken(model: SqlSemanticModel): SqlSemanticToken | undefined { + const range = model.cursorIntent.replacementRange; + if (model.cursorIntent.kind !== "star") return undefined; + return model.tokens.find((token) => token.text === "*" && token.span.start === range.start && token.span.end === range.end); +} + +export function sqlSemanticSelectStarTableSource(model: SqlSemanticModel): SqlSemanticRowSource | undefined { + if (model.cursorIntent.kind !== "star") return undefined; + const source = model.cursorIntent.targetSourceId ? model.rowSources.find((candidate) => candidate.id === model.cursorIntent.targetSourceId) : model.rowSources.length === 1 ? model.rowSources[0] : undefined; + return source?.kind === "table" ? source : undefined; +} + +export function sqlSemanticSelectStarQualifierSql(model: SqlSemanticModel): string | undefined { + const star = selectStarToken(model); + if (!star) return undefined; + const starIndex = model.tokens.indexOf(star); + let index = starIndex - 1; + if (model.tokens[index]?.text !== ".") return undefined; + const qualifierEnd = model.tokens[index]!.span.start; + index -= 1; + if (!isSemanticIdentifier(model.tokens[index])) return undefined; + let qualifierStart = model.tokens[index]!.span.start; + while (index >= 2 && model.tokens[index - 1]?.text === "." && isSemanticIdentifier(model.tokens[index - 2])) { + index -= 2; + qualifierStart = model.tokens[index]!.span.start; + } + return model.sql.slice(qualifierStart, qualifierEnd).trim() || undefined; +} + +export function sqlSemanticSelectStarIsOnlyProjection(model: SqlSemanticModel): boolean { + const star = selectStarToken(model); + if (!star) return false; + let selectIndex = -1; + for (let index = model.tokens.length - 1; index >= 0; index -= 1) { + const token = model.tokens[index]; + if (!token || token.span.end > star.span.start || token.depth !== star.depth) continue; + if (token.kind === "word" && token.normalized === "select") { + selectIndex = index; + break; + } + } + if (selectIndex < 0) return false; + const fromIndex = model.tokens.findIndex((token, index) => index > selectIndex && token.span.start >= star.span.end && token.depth === star.depth && token.kind === "word" && token.normalized === "from"); + const projectionEnd = fromIndex >= 0 ? model.tokens[fromIndex]!.span.start : model.statement.span.end; + const projectionTokens = model.tokens.filter((token) => token.span.start >= model.tokens[selectIndex]!.span.end && token.span.end <= projectionEnd && token.kind !== "comment"); + if (projectionTokens[0]?.kind === "word" && (projectionTokens[0].normalized === "all" || projectionTokens[0].normalized === "distinct")) projectionTokens.shift(); + if (projectionTokens.pop() !== star) return false; + if (projectionTokens.length === 0) return true; + return projectionTokens.length % 2 === 0 && projectionTokens.every((token, index) => (index % 2 === 0 ? isSemanticIdentifier(token) : token.text === ".")); +} + export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] { return model.rowSources .filter((source) => source.kind !== "unknown") diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index f21d2b6e7c..350d6c7527 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -675,7 +675,8 @@ function sourceForQualifier(sources: readonly SqlSemanticRowSource[], qualifierP function starQualifierParts(before: readonly SqlSemanticToken[], starIndex: number, dialect: SqlSemanticDialectAdapter): string[] { let index = starIndex - 1; - if (before[index]?.text === ".") index -= 1; + if (before[index]?.text !== ".") return []; + index -= 1; const qualifierParts: string[] = []; while (index >= 0) { const identifier = before[index]; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index b7ad1de58e..7aa26c1935 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -11,6 +11,7 @@ import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semanti import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; +import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -2861,6 +2862,14 @@ export function quoteSqlIdentifier(identifier: string, dialect?: "mysql" | "post const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase())); +function quoteSelectStarColumnIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType): string { + if (!requiresPostgresIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier; + if (databaseType) return quoteTableIdentifier(databaseType, identifier); + if (dialect === "mysql") return `\`${identifier.replaceAll("`", "``")}\``; + if (dialect === "sqlserver") return `[${identifier.replaceAll("]", "]]")}]`; + return quoteSqlIdentifier(identifier, dialect); +} + function buildTableItems( context: Pick, tables: SqlCompletionTable[], @@ -3167,16 +3176,38 @@ function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordC })); } -function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null { +function selectStarExpansionColumns(context: SqlCompletionContext, columnsByTable: Map): SqlCompletionColumn[] { const columns = context.qualifier ? referencedTablesForSelectAllColumns(context).flatMap((ref) => columnsForSelectAllReferencedTable(ref, columnsByTable)) : [...columnsByTable.values()].flat(); - const uniqueColumns = uniqueColumnsByName(columns); - if (uniqueColumns.length === 0) return null; + return uniqueColumnsByName(columns); +} + +export function selectStarResultColumnsMatch(options: { currentSql: string; targetFrom: number; targetTo: number; statementSql: string; sourceStatement?: string; sourceFrom?: number; sourceTo?: number }): boolean { + if (!options.sourceStatement) return false; + const hasSourceFrom = typeof options.sourceFrom === "number"; + const hasSourceTo = typeof options.sourceTo === "number"; + if (hasSourceFrom !== hasSourceTo) return false; + if (!hasSourceFrom || !hasSourceTo) return options.statementSql === options.sourceStatement; + // 词边界检查:已执行语句可能是当前内容的真前缀(如 `FROM users` → `FROM users_backup`), + // 此时 slice 仍与 sourceStatement 相等,会用旧表列回退到新表。要求 sourceTo 落在标识符边界。 + const sourceToAtBoundary = !/[\w$]/.test(options.currentSql[options.sourceTo!] ?? ""); + return options.targetFrom >= options.sourceFrom! && options.targetTo <= options.sourceTo! && sourceToAtBoundary && options.currentSql.slice(options.sourceFrom, options.sourceTo) === options.sourceStatement; +} + +export function buildSelectStarExpansion(context: SqlCompletionContext, columnsByTable: Map, dialect?: "mysql" | "postgres" | "sqlserver", qualifierSql = context.qualifier, databaseType?: DatabaseType): string | null { + const columns = selectStarExpansionColumns(context, columnsByTable); + if (columns.length === 0) return null; // `alias.*` replaces only the `*`, so the first column must continue the already typed `alias.`. - const expansion = context.qualifier ? buildSelectAllColumnExpansion(uniqueColumns, context.qualifier, true, dialect) : uniqueColumns.map((column) => quoteSqlIdentifier(column.name, dialect)).join(", "); + return qualifierSql ? buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType) : columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", "); +} + +function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null { + const expansion = buildSelectStarExpansion(context, columnsByTable, dialect); + if (!expansion) return null; + const columnCount = selectStarExpansionColumns(context, columnsByTable).length; return { label: "* → columns", type: "snippet" as const, - detail: `${(t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(uniqueColumns.length))}: ${expansion.length > 60 ? expansion.slice(0, 57) + "..." : expansion}`, + detail: `${(t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(columnCount))}: ${expansion.length > 60 ? expansion.slice(0, 57) + "..." : expansion}`, apply: expansion, boost: 1900, }; @@ -3248,10 +3279,10 @@ function referencedTablesForSelectAllColumns(context: SqlCompletionContext): Sql return context.referencedTables.filter((table) => referencedTableMatchesColumnQualifier(table, qualifier, qualifierLower, qualifiedTarget)); } -function buildSelectAllColumnExpansion(columns: SqlCompletionColumn[], qualifier: string | undefined, qualifierAlreadyTyped: boolean, dialect?: "mysql" | "postgres" | "sqlserver"): string { +function buildSelectAllColumnExpansion(columns: SqlCompletionColumn[], qualifier: string | undefined, qualifierAlreadyTyped: boolean, dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType): string { return columns .map((column, index) => { - const columnName = quoteSqlIdentifier(column.name, dialect); + const columnName = quoteSelectStarColumnIdentifier(column.name, dialect, databaseType); if (!qualifier || (qualifierAlreadyTyped && index === 0)) return columnName; return `${qualifier}.${columnName}`; })