feat(completion): fix SQL alias completion, star expansion, and formatter layout

This commit is contained in:
jinmo authored and GitHub committed 2026-08-10 01:11:25 +08:00
1 parent 0402fea37b
commit 868f7961e3
30 files changed
+758 -76

No files matched your search

@@ -821,6 +821,7 @@ const formatterEditorShortcutIds: ShortcutActionId[] = [
"acceptCompletion",
"indentMore",
"indentLess",
"insertLineBelow",
"duplicateLine",
"deleteLine",
"moveLineUp",
@@ -46,7 +46,7 @@ import {
extractCteDefinitions,
} from "@/lib/sql/sqlCompletion";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource } from "@/lib/sql/semantic/completion";
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSources } 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";
@@ -309,6 +309,7 @@ const completionTranslations = computed(() => ({
numericLiteral: t("editor.completion.numericLiteral"),
booleanValue: t("editor.completion.booleanValue"),
starExpansionColumns: t("editor.completion.starExpansionColumns"),
tableAlias: t("editor.completion.tableAlias"),
functionDescriptions: Object.fromEntries(SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)])) as Record<string, string>,
}));
const MAX_COMPLETION_TABLES = 200;
@@ -328,7 +329,7 @@ const contextObjectTarget = ref<SqlObjectNavigationTarget | null>(null);
interface SelectStarExpansionTarget {
from: number;
to: number;
reference: SqlCompletionReferencedTable;
references: SqlCompletionReferencedTable[];
context: SqlCompletionContext;
qualifierSql?: string;
statementSql: string;
@@ -931,6 +932,20 @@ function closePicker() {
view.value?.focus();
}
function insertLineBelow(currentView: EditorViewType): boolean {
if (props.readOnly) return false;
const line = currentView.state.doc.lineAt(currentView.state.selection.main.head);
const indentation = line.text.match(/^\s*/)?.[0] ?? "";
const insertion = `\n${indentation}`;
const cursor = line.to + insertion.length;
currentView.dispatch({
changes: { from: line.to, to: line.to, insert: insertion },
selection: { anchor: cursor },
userEvent: "input.insertLineBelow",
});
return true;
}
function syncContextMenuState(currentView: EditorViewType, starPosition?: number) {
selectedSql.value = selectedSqlFromView(currentView);
executableSql.value = executableSqlFromView(currentView);
@@ -981,23 +996,25 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
}
if (!isSelectProjection) return null;
const source = sqlSemanticSelectStarTableSource(model);
if (!source) return null;
const sources = sqlSemanticSelectStarTableSources(model);
if (sources.length === 0) 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 references = sources.map((source): SqlCompletionReferencedTable => {
const identifierParts = source.qualifiedName?.parts ?? [];
return {
// Use the semantic metadata target instead of reparsing the table token at
// its source span. The latter can resolve the alias token in aliased
// sources, causing column metadata requests for `tv` instead of
// `tVillage`.
name: source.metadataTarget?.table ?? source.name,
nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,
database: source.metadataTarget?.database,
schema: source.metadataTarget?.schema ?? source.qualifierParts[source.qualifierParts.length - 1],
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
alias: source.alias,
aliasSql: source.aliasSpan ? sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
};
});
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;
@@ -1005,11 +1022,11 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
return {
from: intent.replacementRange.start,
to: intent.replacementRange.end,
reference,
context: { ...context, referencedTables: [reference] },
references,
context: { ...context, referencedTables: references },
qualifierSql: sqlSemanticSelectStarQualifierSql(model),
statementSql: model.statement.text,
allowResultColumnsFallback: model.rowSources.length === 1 && sqlSemanticSelectStarIsOnlyProjection(model),
allowResultColumnsFallback: references.length === 1 && model.rowSources.length === 1 && sqlSemanticSelectStarIsOnlyProjection(model),
};
}
@@ -1692,7 +1709,6 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
{
key: "Enter",
run: codeMirrorInsertNewlineKeepIndent ?? undefined,
shift: codeMirrorInsertNewlineKeepIndent ?? undefined,
},
...binding(shortcuts.find, openSearch),
...binding(shortcuts.replace, openReplace),
@@ -1714,6 +1730,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
}),
...binding(shortcuts.indentMore, (view) => codeMirrorIndentMore?.(view) ?? false),
...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false),
...binding(shortcuts.insertLineBelow, insertLineBelow),
...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.deleteLine, (view) => codeMirrorDeleteLine?.(view) ?? false),
...binding(shortcuts.moveLineUp, (view) => codeMirrorMoveLineUp?.(view) ?? false),
@@ -2011,13 +2028,19 @@ function allowsOnDemandQualifiedTableCompletion(prefix: string): boolean {
function completionMetadataTarget(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, scope?: CompletionMetadataScope): { database: string; schema?: string; catalog?: string } | null {
const currentDatabase = scope?.database ?? props.database;
if (currentDatabase == null) return null;
// SQL Server metadata queries require a schema even when the SQL uses an
// unqualified table name. The query editor commonly has no schema selected
// when the user is working from a database-level tab, so use the same
// default as the table/DDL metadata paths instead of returning no columns.
const selectedSchema = table.schema ?? scope?.schema ?? props.schema;
const effectiveSchema = selectedSchema ?? (props.databaseType === "sqlserver" ? metadataSchemaForConnection(connectionStore.getConfig(props.connectionId ?? ""), currentDatabase, undefined) : undefined);
if (supportsDatabaseSchemaQualifierCompletion() && table.database) {
return { database: table.database, schema: table.schema ?? undefined, catalog: table.catalog ?? props.catalog };
return { database: table.database, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
if (supportsDatabaseQualifierCompletion() && table.schema) {
return { database: table.schema, catalog: table.catalog ?? props.catalog };
if (supportsDatabaseQualifierCompletion() && effectiveSchema) {
return { database: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
return { database: currentDatabase, schema: table.schema ?? scope?.schema ?? props.schema, catalog: table.catalog ?? props.catalog };
return { database: currentDatabase, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
function isVirtualCompletionTableReference(table: { name: string; database?: string | null; schema?: string | null }): boolean {
@@ -2072,15 +2095,68 @@ async function ensureColumnsForTable(table: { name: string; database?: string |
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());
let columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog, reference);
// A schema-aware connection can legitimately return an empty result when
// the editor has no selected schema. Resolve the physical table from the
// local/remote table cache and retry with its schema before reporting that
// star expansion is unavailable. This is especially important for aliased
// sources because the alias itself must never be sent as the table name.
if (columns.length === 0 && !table.schema && !target.schema && !supportsDatabaseQualifierCompletion()) {
const schemaCandidates: string[] = [];
const seenSchemas = new Set<string>();
const addSchema = (schema?: string | null) => {
const normalized = schema?.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seenSchemas.has(key)) return;
seenSchemas.add(key);
schemaCandidates.push(normalized);
};
if (props.databaseType === "sqlserver") {
addSchema(metadataSchemaForConnection(connectionStore.getConfig(props.connectionId), target.database, undefined));
}
const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, target.catalog);
localTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
if (schemaCandidates.length === 0 && !usesLocalOnlyCompletionMetadata()) {
const remoteTables = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, false, undefined, target.catalog);
remoteTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
}
for (const schema of schemaCandidates) {
const schemaTarget = completionMetadataTarget({ ...table, schema });
if (!schemaTarget) continue;
const retryColumns = await listCompletionColumnsForEditor(props.connectionId, schemaTarget.database, table.name, schemaTarget.schema, schemaTarget.catalog, reference);
if (retryColumns.length > 0) {
columns = retryColumns;
break;
}
}
}
// Do not memoize an empty response as a successful load. Empty results are
// commonly caused by a temporarily unresolved schema; keeping that value
// would prevent the next expansion attempt from retrying after metadata has
// become available.
if (columns.length > 0) {
cachedColumnsByTable.set(cacheKey, columns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
} else {
cachedColumnsByTable.delete(cacheKey);
loadedColumnsByTable.delete(cacheKey.toLowerCase());
}
return true;
}
function resultColumnsForSelectStar(target: SelectStarExpansionTarget, sql: string): SqlCompletionColumn[] {
if (
!target.allowResultColumnsFallback ||
target.references.length !== 1 ||
!selectStarResultColumnsMatch({
currentSql: sql,
targetFrom: target.from,
@@ -2095,7 +2171,7 @@ function resultColumnsForSelectStar(target: SelectStarExpansionTarget, sql: stri
return (props.resultColumns ?? [])
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ name, table: target.reference.name, schema: target.reference.schema }));
.map((name) => ({ name, table: target.references[0]!.name, schema: target.references[0]!.schema }));
}
async function expandSelectStar(target = selectStarExpansionTarget.value) {
@@ -2105,19 +2181,25 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) {
const originalDocument = currentView.state.doc.toString();
try {
await ensureColumnsForTable(target.reference, target.reference);
await Promise.all(target.references.map((reference) => ensureColumnsForTable(reference, 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 (view.value !== currentView || currentView.state.doc.toString() !== originalDocument || currentView.state.sliceDoc(target.from, target.to) !== "*") return;
const columnsByReference = new Map<string, SqlCompletionColumn[]>();
for (const reference of target.references) {
const columns = cachedColumnsByTable.get(completionCacheKey(reference));
const expansionColumns = columns?.length ? columns : target.references.length === 1 ? resultColumnsForSelectStar(target, originalDocument) : [];
if (expansionColumns.length === 0) {
toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000);
return;
}
columnsByReference.set(completionCacheKey(reference), expansionColumns);
}
const expansion = buildSelectStarExpansion(target.context, columnsByReference, props.dialect, target.qualifierSql, props.databaseType);
if (!expansion) {
toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000);
return;
@@ -19,6 +19,7 @@ import {
syncSqlFormatterConfigDraft,
type SqlFormatterCase,
type SqlFormatterExpressionWidth,
type SqlFormatterFromClauseLayout,
type SqlFormatterIndentStyle,
type SqlFormatterLinesBetweenQueries,
type SqlFormatterLogicalOperatorNewline,
@@ -77,6 +78,12 @@ const caseOptions: { value: SqlFormatterCase; labelKey: string }[] = [
const logicalOperatorOptions: { value: SqlFormatterLogicalOperatorNewline; labelKey: string }[] = [
{ value: "before", labelKey: "settings.sqlFormatterLogicalBefore" },
{ value: "after", labelKey: "settings.sqlFormatterLogicalAfter" },
{ value: "none", labelKey: "settings.sqlFormatterLogicalSameLine" },
];
const fromClauseLayoutOptions: { value: SqlFormatterFromClauseLayout; labelKey: string }[] = [
{ value: "newLine", labelKey: "settings.sqlFormatterFromNewLine" },
{ value: "sameLine", labelKey: "settings.sqlFormatterFromSameLine" },
];
const indentStyleOptions: { value: SqlFormatterIndentStyle; labelKey: string }[] = [
@@ -97,6 +104,7 @@ const sqlFormatterOptionLabelKeys: Record<keyof SqlFormatterOptionSettings, stri
useTabs: "settings.sqlFormatterIndent",
tabWidth: "settings.sqlFormatterTabWidth",
logicalOperatorNewline: "settings.sqlFormatterLogicalOperatorNewline",
fromClauseLayout: "settings.sqlFormatterFromClauseLayout",
expressionWidth: "settings.sqlFormatterExpressionWidth",
linesBetweenQueries: "settings.sqlFormatterLinesBetweenQueries",
denseOperators: "settings.sqlFormatterDenseOperators",
@@ -184,7 +192,11 @@ function onIndentStyle(value: any) {
}
function onLogicalOperatorNewline(value: any) {
if (value === "before" || value === "after") updateOption("logicalOperatorNewline", value);
if (value === "before" || value === "after" || value === "none") updateOption("logicalOperatorNewline", value);
}
function onFromClauseLayout(value: any) {
if (value === "newLine" || value === "sameLine") updateOption("fromClauseLayout", value);
}
function onTabWidth(value: any) {
@@ -617,6 +629,20 @@ onBeforeUnmount(() => {
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.sqlFormatterFromClauseLayout") }}</Label>
<Select :model-value="settings.fromClauseLayout" @update:model-value="onFromClauseLayout">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in fromClauseLayoutOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.sqlFormatterExpressionWidth") }}</Label>
<Select :model-value="String(settings.expressionWidth)" @update:model-value="onExpressionWidth">