From b8511051cd97b7cdb542ef50171c91c79b22b006 Mon Sep 17 00:00:00 2001 From: luoianun Date: Wed, 24 Jun 2026 14:48:47 +0800 Subject: [PATCH] feat(editor): auto alias SQL table completions * feat: auto alias SQL table completions * fix: preserve alias suggestion fallback order --------- Co-authored-by: t8y2 <1156263951@qq.com> --- .../editor/EditorSettingsDialog.vue | 14 ++++ .../src/components/editor/QueryEditor.vue | 4 ++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/sqlCompletion.context.spec.ts | 62 +++++++++++++++++ apps/desktop/src/lib/sqlCompletion.ts | 69 ++++++++++++++----- .../stores/__tests__/settingsStore.spec.ts | 12 ++++ apps/desktop/src/stores/settingsStore.ts | 4 ++ packages/app-tests/sqlCompletion.test.ts | 13 ++++ 14 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/stores/__tests__/settingsStore.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f4ac9d0503..c2aa6fd20c 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -103,6 +103,7 @@ const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThe const showThemeCustomizer = ref(false); const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker); +const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution); const editAppLayout = ref(settingsStore.editorSettings.appLayout); @@ -365,6 +366,7 @@ watch( editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId; editExecuteMode.value = settingsStore.editorSettings.executeMode; editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker; + editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editWordWrap.value = settingsStore.editorSettings.wordWrap; editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution; editAppLayout.value = settingsStore.editorSettings.appLayout; @@ -423,6 +425,7 @@ function hasChanges(): boolean { editActiveCustomThemeId.value !== settingsStore.editorSettings.activeCustomThemeId || editExecuteMode.value !== settingsStore.editorSettings.executeMode || editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker || + editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables || editWordWrap.value !== settingsStore.editorSettings.wordWrap || editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution || editAppLayout.value !== settingsStore.editorSettings.appLayout || @@ -466,6 +469,7 @@ async function persistSettings() { activeCustomThemeId: editActiveCustomThemeId.value, executeMode: editExecuteMode.value, showExecutionTargetPicker: editShowExecutionTargetPicker.value, + autoAliasTables: editAutoAliasTables.value, wordWrap: editWordWrap.value, confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value, appLayout: editAppLayout.value, @@ -519,6 +523,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; + editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution; } else if (tab === "formatter") { @@ -572,6 +577,7 @@ function resetAllDefaults() { editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; + editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution; editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; @@ -1697,6 +1703,14 @@ watch( + +
+
+ +

{{ t("settings.autoAliasTablesDescription") }}

+
+ +
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2f1852d1e2..eee5bbe5cc 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1250,6 +1250,7 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E dialect: props.dialect, databaseType: props.databaseType, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, + autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); } @@ -1268,6 +1269,7 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E dialect: props.dialect, databaseType: props.databaseType, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, + autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); } @@ -1412,6 +1414,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType { }); }); +describe("sqlCompletion table aliases", () => { + it("applies generated aliases to table completions when enabled", () => { + const sql = "SELECT * FROM ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order_items", type: "table" }], + columnsByTable: new Map(), + autoAliasTables: true, + }); + + const table = items.find((item) => item.label === "order_items" && item.type === "table"); + expect(table?.apply).toBe("order_items AS oi"); + }); + + it("keeps plain table completions when generated aliases are disabled", () => { + const sql = "SELECT * FROM ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order_items", type: "table" }], + columnsByTable: new Map(), + autoAliasTables: false, + }); + + const table = items.find((item) => item.label === "order_items" && item.type === "table"); + expect(table?.apply).toBe("order_items"); + }); + + it("uses a numbered alias when the generated table alias already exists", () => { + const sql = "SELECT * FROM order_items oi JOIN ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order_items", type: "table" }], + columnsByTable: new Map(), + autoAliasTables: true, + }); + + const table = items.find((item) => item.label === "order_items" && item.type === "table"); + expect(table?.apply).toBe("order_items AS oi2"); + }); + + it("applies generated aliases in comma-separated FROM table lists", () => { + const sql = "SELECT * FROM users u, ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order_items", type: "table" }], + columnsByTable: new Map(), + autoAliasTables: true, + }); + + const table = items.find((item) => item.label === "order_items" && item.type === "table"); + expect(table?.apply).toBe("order_items AS oi"); + }); + + it("does not apply generated aliases to non-query table completions", () => { + const sql = "INSERT INTO ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order_items", type: "table" }], + columnsByTable: new Map(), + autoAliasTables: true, + }); + + const table = items.find((item) => item.label === "order_items" && item.type === "table"); + expect(table?.apply).toBe("order_items"); + }); +}); + describe("sqlCompletion scoped context classification", () => { it("classifies JOIN table contexts", () => { const sql = "SELECT * FROM users u JOIN "; diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index e49378f563..73cb8d91a1 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1092,6 +1092,7 @@ export interface SqlCompletionContext { updateTarget?: { table: string; schema?: string }; deleteTarget?: { table: string; schema?: string }; oracleTableFunctionContext?: boolean; + autoAliasTableCompletions: boolean; contextKind: SqlCompletionContextKind; } @@ -1124,6 +1125,7 @@ export interface SqlCompletionProviderInput { dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; keywordCase?: SqlKeywordCase; + autoAliasTables?: boolean; } export function buildSqlCompletionItems( @@ -1139,6 +1141,7 @@ export function buildSqlCompletionItems( dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; keywordCase?: SqlKeywordCase; + autoAliasTables?: boolean; }, ): SqlCompletionItem[] { const context = getSqlCompletionContext(sql, cursor); @@ -1217,7 +1220,7 @@ class SqlCompletionProvider { if (!context.exclusiveColumnSuggestions && context.suggestTables) { this.items.push(...buildForeignKeyRelatedTableItems(context, this.input.tables, this.input.foreignKeysByTable, this.dialect)); - this.items.push(...buildTableItems(context.prefix, this.input.tables, this.dialect)); + this.items.push(...buildTableItems(context.prefix, this.input.tables, this.dialect, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables)); if (isOracleLikeDatabase(this.databaseType)) { this.items.push(...buildOracleTableFunctionItems(context.prefix)); } @@ -1542,6 +1545,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const afterTableTrigger = TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); const exclusiveTableSuggestions = EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); + const autoAliasTableCompletions = lastWord === "from" || lastWord === "join" || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions && !insertInfo; // Check if we're in a context where columns are expected @@ -1597,6 +1601,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet updateTarget: updateInfo?.target, deleteTarget: deleteInfo?.target, oracleTableFunctionContext, + autoAliasTableCompletions, contextKind, }; } @@ -2358,16 +2363,21 @@ function requiresPostgresIdentifierQuote(identifier: string): boolean { const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(["current_user", "session_user", "user"])); -function buildTableItems(prefix: string, tables: SqlCompletionTable[], dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { +function buildTableItems(prefix: string, tables: SqlCompletionTable[], dialect?: "mysql" | "postgres" | "sqlserver", autoAliasTables = false, referencedTables: SqlCompletionReferencedTable[] = []): SqlCompletionItem[] { + const existingAliases = new Set(referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias)); return tables .filter((table) => matchesPrefix(table.name, prefix)) - .map((table) => ({ - label: table.name, - type: "table" as const, - detail: table.schema ? `${table.schema}.${table.name}` : table.type, - apply: quoteSqlIdentifier(table.name, dialect), - boost: computeBoost(table.name, prefix) + 1000, - })) + .map((table) => { + const applyName = quoteSqlIdentifier(table.name, dialect); + const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : ""; + return { + label: table.name, + type: "table" as const, + detail: table.schema ? `${table.schema}.${table.name}` : table.type, + apply: alias ? `${applyName} AS ${alias}` : applyName, + boost: computeBoost(table.name, prefix) + 1000, + }; + }) .sort(compareCompletionItems) .slice(0, MAX_TABLE_COMPLETION_ITEMS); } @@ -2666,6 +2676,36 @@ function buildAliasItems(context: SqlCompletionContext): SqlCompletionItem[] { } function generateAlias(tableName: string, existing = new Set()): string { + const candidates = buildAliasCandidates(tableName); + + for (const candidate of candidates.filter(Boolean)) { + if (!aliasConflicts(candidate, existing)) return candidate; + } + + const fallback = candidates.find(Boolean) ?? "tb"; + for (let index = 2; index < 100; index++) { + const candidate = `${fallback}${index}`; + if (!aliasConflicts(candidate, existing)) return candidate; + } + return fallback; +} + +function generateTableCompletionAlias(tableName: string, existing = new Set()): string { + const candidates = buildAliasCandidates(tableName); + + for (const candidate of candidates.filter(Boolean)) { + if (SQL_ALIAS_RESERVED_WORDS.has(candidate.toLowerCase())) continue; + if (!existing.has(candidate.toLowerCase())) return candidate; + for (let index = 2; index < 100; index++) { + const numbered = `${candidate}${index}`; + if (!aliasConflicts(numbered, existing)) return numbered; + } + } + + return generateAlias(tableName, existing); +} + +function buildAliasCandidates(tableName: string): string[] { const parts = identifierWords(tableName); const candidates: string[] = []; @@ -2685,16 +2725,7 @@ function generateAlias(tableName: string, existing = new Set()): string if (chars.length >= 3) candidates.push(chars.slice(0, 3).join("")); } - for (const candidate of candidates.filter(Boolean)) { - if (!aliasConflicts(candidate, existing)) return candidate; - } - - const fallback = candidates.find(Boolean) ?? "tb"; - for (let index = 2; index < 100; index++) { - const candidate = `${fallback}${index}`; - if (!aliasConflicts(candidate, existing)) return candidate; - } - return fallback; + return candidates; } function aliasConflicts(candidate: string, existing: Set): boolean { diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts new file mode 100644 index 0000000000..b725c7ec5a --- /dev/null +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEditorSettings } from "@/stores/settingsStore"; + +describe("normalizeEditorSettings", () => { + it("enables automatic table aliases by default", () => { + expect(normalizeEditorSettings({}).autoAliasTables).toBe(true); + }); + + it("preserves disabled automatic table aliases", () => { + expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false); + }); +}); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 6c427efcc7..612bd33b27 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -308,6 +308,7 @@ export interface EditorSettings { activeCustomThemeId: string; executeMode: "all" | "current"; showExecutionTargetPicker: boolean; + autoAliasTables: boolean; wordWrap: boolean; confirmDangerousSqlExecution: boolean; compactTabTitle: boolean; @@ -407,6 +408,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { activeCustomThemeId: "default", executeMode: "all", showExecutionTargetPicker: false, + autoAliasTables: true, wordWrap: false, confirmDangerousSqlExecution: true, compactTabTitle: false, @@ -571,6 +573,7 @@ export function normalizeEditorSettings(settings: Partial, exist activeCustomThemeId: settings.activeCustomThemeId ?? "default", executeMode: settings.executeMode ?? DEFAULT_EDITOR_SETTINGS.executeMode, showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker, + autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, confirmDangerousSqlExecution: settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution, compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle, @@ -730,6 +733,7 @@ export const useSettingsStore = defineStore("settings", () => { } if (partial.executeMode !== undefined) editorSettings.value.executeMode = partial.executeMode; if (partial.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker; + if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; if (partial.confirmDangerousSqlExecution !== undefined) editorSettings.value.confirmDangerousSqlExecution = partial.confirmDangerousSqlExecution; if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index fe39d1f5b6..a06b389b6e 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1300,6 +1300,19 @@ test("table alias suggestions avoid reserved words", () => { assert.equal(aliasItem!.apply, "AS ord "); }); +test("automatic table aliases avoid reserved words", () => { + const items = buildSqlCompletionItems("select * from ord", "select * from ord".length, { + tables, + columnsByTable, + autoAliasTables: true, + }); + + const tableItem = items.find((item) => item.type === "table" && item.label === "orders"); + assert.ok(tableItem); + assert.notEqual(tableItem!.apply, "orders AS or"); + assert.equal(tableItem!.apply, "orders AS ord"); +}); + test("table alias suggestions avoid existing aliases", () => { const items = buildSqlCompletionItems("select * from customer_orders co join customer_orders ", "select * from customer_orders co join customer_orders ".length, { tables: [...tables, { name: "customer_orders", schema: "public", type: "table" }],