feat(completion): fix SQL alias completion, star expansion, and formatter layout
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user