feat(completion): fix SQL alias completion, star expansion, and formatter layout
This commit is contained in:
parent
0402fea37b
commit
868f7961e3
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -999,6 +999,7 @@ export default {
|
|||
numericLiteral: "Numeric literal",
|
||||
booleanValue: "Boolean value",
|
||||
starExpansionColumns: "{count} columns",
|
||||
tableAlias: "Table alias",
|
||||
functionDescriptions: {
|
||||
COUNT: "Returns the number of rows",
|
||||
SUM: "Returns the sum of a numeric column",
|
||||
|
|
@ -5057,6 +5058,10 @@ export default {
|
|||
sqlFormatterLogicalOperatorNewline: "Logical operator newline",
|
||||
sqlFormatterLogicalBefore: "Before operator",
|
||||
sqlFormatterLogicalAfter: "After operator",
|
||||
sqlFormatterLogicalSameLine: "Keep on same line",
|
||||
sqlFormatterFromClauseLayout: "FROM clause layout",
|
||||
sqlFormatterFromNewLine: "Table on next line",
|
||||
sqlFormatterFromSameLine: "FROM with first table",
|
||||
sqlFormatterExpressionWidth: "Expression width",
|
||||
sqlFormatterLinesBetweenQueries: "Lines between queries",
|
||||
sqlFormatterDenseOperators: "Dense operators",
|
||||
|
|
@ -5504,6 +5509,7 @@ export default {
|
|||
shortcutToggleLineComment: "Toggle line comment",
|
||||
shortcutIndentMore: "Indent more",
|
||||
shortcutIndentLess: "Indent less",
|
||||
shortcutInsertLineBelow: "Insert line below",
|
||||
shortcutDuplicateLine: "Duplicate current line",
|
||||
shortcutDeleteLine: "Delete current line",
|
||||
shortcutMoveLineUp: "Move line up",
|
||||
|
|
|
|||
|
|
@ -976,6 +976,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "Literal numérico",
|
||||
booleanValue: "Valor booleano",
|
||||
starExpansionColumns: "{count} columnas",
|
||||
tableAlias: "Alias de tabla",
|
||||
functionDescriptions: {
|
||||
COUNT: "Devuelve el número de filas",
|
||||
SUM: "Devuelve la suma de una columna numérica",
|
||||
|
|
@ -4817,6 +4818,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "Salto de línea en operador lógico",
|
||||
sqlFormatterLogicalBefore: "Antes del operador",
|
||||
sqlFormatterLogicalAfter: "Después del operador",
|
||||
sqlFormatterLogicalSameLine: "Mantener en la misma línea",
|
||||
sqlFormatterFromClauseLayout: "Diseño de la cláusula FROM",
|
||||
sqlFormatterFromNewLine: "Tabla en la línea siguiente",
|
||||
sqlFormatterFromSameLine: "FROM con la primera tabla",
|
||||
sqlFormatterExpressionWidth: "Ancho de expresión",
|
||||
sqlFormatterLinesBetweenQueries: "Líneas entre consultas",
|
||||
sqlFormatterDenseOperators: "Operadores compactos",
|
||||
|
|
@ -5221,6 +5226,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "Alternar comentario de línea",
|
||||
shortcutIndentMore: "Aumentar sangría",
|
||||
shortcutIndentLess: "Reducir sangría",
|
||||
shortcutInsertLineBelow: "Insertar una línea debajo",
|
||||
shortcutDuplicateLine: "Duplicar línea actual",
|
||||
shortcutDeleteLine: "Eliminar línea actual",
|
||||
shortcutMoveLineUp: "Mover línea arriba",
|
||||
|
|
|
|||
|
|
@ -974,6 +974,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "Costante numerica",
|
||||
booleanValue: "Valore booleano",
|
||||
starExpansionColumns: "{count} colonne",
|
||||
tableAlias: "Alias della tabella",
|
||||
functionDescriptions: {
|
||||
COUNT: "Restituisce il numero di righe",
|
||||
SUM: "Restituisce la somma di una colonna numerica",
|
||||
|
|
@ -4817,6 +4818,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "Nuova riga operatore logico",
|
||||
sqlFormatterLogicalBefore: "Prima dell'operatore",
|
||||
sqlFormatterLogicalAfter: "Dopo l'operatore",
|
||||
sqlFormatterLogicalSameLine: "Mantieni sulla stessa riga",
|
||||
sqlFormatterFromClauseLayout: "Layout della clausola FROM",
|
||||
sqlFormatterFromNewLine: "Tabella sulla riga successiva",
|
||||
sqlFormatterFromSameLine: "FROM con la prima tabella",
|
||||
sqlFormatterExpressionWidth: "Larghezza espressione",
|
||||
sqlFormatterLinesBetweenQueries: "Righe tra query",
|
||||
sqlFormatterDenseOperators: "Operatori compatti",
|
||||
|
|
@ -5221,6 +5226,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "Attiva/disattiva commento di riga",
|
||||
shortcutIndentMore: "Aumenta rientro",
|
||||
shortcutIndentLess: "Riduci rientro",
|
||||
shortcutInsertLineBelow: "Inserisci una riga sotto",
|
||||
shortcutDuplicateLine: "Duplica riga corrente",
|
||||
shortcutDeleteLine: "Elimina riga corrente",
|
||||
shortcutMoveLineUp: "Sposta riga su",
|
||||
|
|
|
|||
|
|
@ -994,6 +994,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "数値リテラル",
|
||||
booleanValue: "真偽値",
|
||||
starExpansionColumns: "{count}列",
|
||||
tableAlias: "テーブルエイリアス",
|
||||
functionDescriptions: {
|
||||
COUNT: "行数を返す",
|
||||
SUM: "数値列の合計を返す",
|
||||
|
|
@ -4858,6 +4859,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "論理演算子の改行",
|
||||
sqlFormatterLogicalBefore: "演算子の前",
|
||||
sqlFormatterLogicalAfter: "演算子の後",
|
||||
sqlFormatterLogicalSameLine: "同じ行に保持",
|
||||
sqlFormatterFromClauseLayout: "FROM 句のレイアウト",
|
||||
sqlFormatterFromNewLine: "テーブルを次の行に配置",
|
||||
sqlFormatterFromSameLine: "FROM と最初のテーブルを同じ行に配置",
|
||||
sqlFormatterExpressionWidth: "式の幅",
|
||||
sqlFormatterLinesBetweenQueries: "クエリ間の行数",
|
||||
sqlFormatterDenseOperators: "演算子を詰める",
|
||||
|
|
@ -5238,6 +5243,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "行コメントを切り替え",
|
||||
shortcutIndentMore: "インデントを増やす",
|
||||
shortcutIndentLess: "インデントを減らす",
|
||||
shortcutInsertLineBelow: "下に行を挿入",
|
||||
shortcutDuplicateLine: "現在行を複製",
|
||||
shortcutDeleteLine: "現在行を削除",
|
||||
shortcutMoveLineUp: "行を上に移動",
|
||||
|
|
|
|||
|
|
@ -893,6 +893,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "숫자 리터럴",
|
||||
booleanValue: "불리언 값",
|
||||
starExpansionColumns: "{count}개 컬럼",
|
||||
tableAlias: "테이블 별칭",
|
||||
functionDescriptions: {
|
||||
COUNT: "행 수를 반환합니다",
|
||||
SUM: "숫자 컬럼의 합을 반환합니다",
|
||||
|
|
@ -4567,6 +4568,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "논리 연산자 줄바꿈",
|
||||
sqlFormatterLogicalBefore: "연산자 앞",
|
||||
sqlFormatterLogicalAfter: "연산자 뒤",
|
||||
sqlFormatterLogicalSameLine: "같은 줄 유지",
|
||||
sqlFormatterFromClauseLayout: "FROM 절 레이아웃",
|
||||
sqlFormatterFromNewLine: "다음 줄에 테이블 배치",
|
||||
sqlFormatterFromSameLine: "FROM과 첫 번째 테이블을 같은 줄에 배치",
|
||||
sqlFormatterExpressionWidth: "표현식 너비",
|
||||
sqlFormatterLinesBetweenQueries: "쿼리 사이 줄 수",
|
||||
sqlFormatterDenseOperators: "조밀한 연산자",
|
||||
|
|
@ -5000,6 +5005,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "줄 주석 전환",
|
||||
shortcutIndentMore: "들여쓰기 늘리기",
|
||||
shortcutIndentLess: "들여쓰기 줄이기",
|
||||
shortcutInsertLineBelow: "아래에 줄 삽입",
|
||||
shortcutDuplicateLine: "현재 줄 복제",
|
||||
shortcutDeleteLine: "현재 줄 삭제",
|
||||
shortcutMoveLineUp: "줄 위로 이동",
|
||||
|
|
|
|||
|
|
@ -975,6 +975,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "Literal numérico",
|
||||
booleanValue: "Valor booleano",
|
||||
starExpansionColumns: "{count} colunas",
|
||||
tableAlias: "Alias da tabela",
|
||||
functionDescriptions: {
|
||||
COUNT: "Retorna o número de linhas",
|
||||
SUM: "Retorna a soma de uma coluna numérica",
|
||||
|
|
@ -4819,6 +4820,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "Quebra de linha no operador lógico",
|
||||
sqlFormatterLogicalBefore: "Antes do operador",
|
||||
sqlFormatterLogicalAfter: "Depois do operador",
|
||||
sqlFormatterLogicalSameLine: "Manter na mesma linha",
|
||||
sqlFormatterFromClauseLayout: "Layout da cláusula FROM",
|
||||
sqlFormatterFromNewLine: "Tabela na linha seguinte",
|
||||
sqlFormatterFromSameLine: "FROM com a primeira tabela",
|
||||
sqlFormatterExpressionWidth: "Largura da expressão",
|
||||
sqlFormatterLinesBetweenQueries: "Linhas entre consultas",
|
||||
sqlFormatterDenseOperators: "Operadores compactos",
|
||||
|
|
@ -5223,6 +5228,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "Alternar comentário de linha",
|
||||
shortcutIndentMore: "Aumentar recuo",
|
||||
shortcutIndentLess: "Reduzir recuo",
|
||||
shortcutInsertLineBelow: "Inserir uma linha abaixo",
|
||||
shortcutDuplicateLine: "Duplicar linha atual",
|
||||
shortcutDeleteLine: "Excluir linha atual",
|
||||
shortcutMoveLineUp: "Mover linha para cima",
|
||||
|
|
|
|||
|
|
@ -999,6 +999,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "数值字面量",
|
||||
booleanValue: "布尔值",
|
||||
starExpansionColumns: "{count} 列",
|
||||
tableAlias: "表别名",
|
||||
functionDescriptions: {
|
||||
COUNT: "返回行数",
|
||||
SUM: "返回数值列的总和",
|
||||
|
|
@ -5054,6 +5055,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "逻辑运算符换行",
|
||||
sqlFormatterLogicalBefore: "运算符前换行",
|
||||
sqlFormatterLogicalAfter: "运算符后换行",
|
||||
sqlFormatterLogicalSameLine: "逻辑条件保持同一行",
|
||||
sqlFormatterFromClauseLayout: "FROM 子句布局",
|
||||
sqlFormatterFromNewLine: "FROM 与表名分行",
|
||||
sqlFormatterFromSameLine: "FROM 与首个表同行",
|
||||
sqlFormatterExpressionWidth: "表达式宽度",
|
||||
sqlFormatterLinesBetweenQueries: "查询之间空行",
|
||||
sqlFormatterDenseOperators: "紧凑运算符",
|
||||
|
|
@ -5500,6 +5505,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "切换行注释",
|
||||
shortcutIndentMore: "增加缩进",
|
||||
shortcutIndentLess: "减少缩进",
|
||||
shortcutInsertLineBelow: "在下方新增一行",
|
||||
shortcutDuplicateLine: "复制当前行",
|
||||
shortcutDeleteLine: "删除当前行",
|
||||
shortcutMoveLineUp: "上移当前行",
|
||||
|
|
|
|||
|
|
@ -974,6 +974,7 @@ export default withEnglishFallback({
|
|||
numericLiteral: "數值常值",
|
||||
booleanValue: "布林值",
|
||||
starExpansionColumns: "{count} 欄",
|
||||
tableAlias: "資料表別名",
|
||||
functionDescriptions: {
|
||||
COUNT: "回傳列數",
|
||||
SUM: "回傳數值欄位的總和",
|
||||
|
|
@ -4269,6 +4270,10 @@ export default withEnglishFallback({
|
|||
sqlFormatterLogicalOperatorNewline: "邏輯運算子換行",
|
||||
sqlFormatterLogicalBefore: "運算子前換行",
|
||||
sqlFormatterLogicalAfter: "運算子後換行",
|
||||
sqlFormatterLogicalSameLine: "邏輯條件保持同一行",
|
||||
sqlFormatterFromClauseLayout: "FROM 子句版面",
|
||||
sqlFormatterFromNewLine: "表名另起一行",
|
||||
sqlFormatterFromSameLine: "FROM 與第一個表同行",
|
||||
sqlFormatterExpressionWidth: "運算式寬度",
|
||||
sqlFormatterLinesBetweenQueries: "查詢之間空行",
|
||||
sqlFormatterDenseOperators: "緊湊運算子",
|
||||
|
|
@ -4657,6 +4662,7 @@ export default withEnglishFallback({
|
|||
shortcutToggleLineComment: "切換行註解",
|
||||
shortcutIndentMore: "增加縮排",
|
||||
shortcutIndentLess: "減少縮排",
|
||||
shortcutInsertLineBelow: "在下方新增一行",
|
||||
shortcutDuplicateLine: "複製目前行",
|
||||
shortcutDeleteLine: "刪除目前行",
|
||||
shortcutMoveLineUp: "上移目前行",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ describe("QueryEditor execution routing", () => {
|
|||
// The picker guard must also honor the shortcut's bypass flag, otherwise Ctrl+Enter would keep popping the dialog.
|
||||
expect(queryEditorSource).toContain("if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker");
|
||||
});
|
||||
|
||||
it("inserts a complete indented line below the current line", () => {
|
||||
expect(queryEditorSource).toContain('userEvent: "input.insertLineBelow"');
|
||||
expect(queryEditorSource).toContain("changes: { from: line.to, to: line.to, insert: insertion }");
|
||||
expect(queryEditorSource).toContain("const cursor = line.to + insertion.length");
|
||||
expect(queryEditorSource).not.toMatch(/key:\s*"Enter"[\s\S]{0,180}shift:\s*codeMirrorInsertNewlineKeepIndent/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentArea execution summary errors", () => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,15 @@ describe("shortcutRegistry editor actions", () => {
|
|||
expect(findShortcutConflict("expandSelectStar", DEFAULT_SHORTCUT_SETTINGS.expandSelectStar, DEFAULT_SHORTCUT_SETTINGS)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Shift+Enter for inserting a complete line below", () => {
|
||||
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "insertLineBelow");
|
||||
|
||||
expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Shift+Enter" });
|
||||
expect(DEFAULT_SHORTCUT_SETTINGS.insertLineBelow).toBe("Shift+Enter");
|
||||
expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.insertLineBelow)).toBe("Shift-Enter");
|
||||
expect(findShortcutConflict("insertLineBelow", DEFAULT_SHORTCUT_SETTINGS.insertLineBelow, 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());
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ describe("SQL completion insertion", () => {
|
|||
expect(appendSqlCompletionSpace("public.", { enabled: true, itemType: "schema" })).toBe("public.");
|
||||
expect(appendSqlCompletionSpace("name", { enabled: true, itemType: "property" })).toBe("name");
|
||||
expect(appendSqlCompletionSpace("key", { enabled: true, itemType: "text" })).toBe("key");
|
||||
expect(appendSqlCompletionSpace("tt", { enabled: true, itemType: "variable" })).toBe("tt");
|
||||
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: ")" })).toBe("orders");
|
||||
expect(appendSqlCompletionSpace("orders", { enabled: true, itemType: "table", nextCharacter: "," })).toBe("orders");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -281,6 +281,29 @@ describe("semantic SQL completion candidates", () => {
|
|||
expect(columns.find((item) => item.label === "v.id")?.apply).toBe("v.id");
|
||||
});
|
||||
|
||||
it("prioritizes matching table aliases over matching columns", () => {
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>([["test_tb", ["title", "type"].map((name) => ({ name, table: "test_tb" }))]]);
|
||||
|
||||
const { items } = semanticCompletion("SELECT * FROM test_tb AS tt WHERE t|", { columnsByTable });
|
||||
|
||||
expect(items[0]).toMatchObject({ label: "tt", type: "text", apply: "tt" });
|
||||
expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(expect.arrayContaining(["title", "type"]));
|
||||
});
|
||||
|
||||
it("qualifies both unique and duplicate columns in multi-table queries", () => {
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
|
||||
["tVillage", ["villageId", "villageName"].map((name) => ({ name, table: "tVillage" }))],
|
||||
["tland", ["villageId", "landName"].map((name) => ({ name, table: "tland" }))],
|
||||
]);
|
||||
|
||||
const { items } = semanticCompletion("SELECT vill| FROM tVillage tV INNER JOIN tland tl ON tV.villageId = tl.villageId", { columnsByTable });
|
||||
const columns = items.filter((item) => item.type === "column");
|
||||
|
||||
expect(columns.map((item) => item.label)).toEqual(expect.arrayContaining(["tV.villageId", "tV.villageName", "tl.villageId"]));
|
||||
expect(columns.find((item) => item.label === "tV.villageName")).toMatchObject({ filterText: "villageName", apply: "tV.villageName" });
|
||||
expect(columns.find((item) => item.label === "tl.villageId")).toMatchObject({ filterText: "villageId", apply: "tl.villageId" });
|
||||
});
|
||||
|
||||
it("completes columns for aliases in comma-separated table lists", () => {
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
|
||||
["table_a", ["id", "name"].map((name) => ({ name, table: "table_a" }))],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource } from "@/lib/sql/semantic/completion";
|
||||
import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource, sqlSemanticSelectStarTableSources } from "@/lib/sql/semantic/completion";
|
||||
import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sql/semantic/fixtures";
|
||||
import { buildSqlSemanticModel, sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model";
|
||||
|
||||
|
|
@ -51,6 +51,13 @@ describe("sqlSemanticModel baseline fixtures", () => {
|
|||
expect(sqlSemanticSelectStarTableSource(model)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns every table source for an unqualified multi-table star", () => {
|
||||
const { sql, cursor } = sqlFixtureCursor("select *| from tVillage tV inner join tland tl on tV.villageId = tl.villageId");
|
||||
const model = buildSqlSemanticModel(sql, cursor);
|
||||
|
||||
expect(sqlSemanticSelectStarTableSources(model).map((source) => source.alias)).toEqual(["tV", "tl"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["select *| from apis", true],
|
||||
["select distinct *| from apis", true],
|
||||
|
|
|
|||
|
|
@ -39,6 +39,62 @@ describe("SELECT star expansion", () => {
|
|||
).toBe("id, created_at, method");
|
||||
});
|
||||
|
||||
it("expands a multi-table star with aliases and preserves duplicate column names", () => {
|
||||
const sql = "SELECT * FROM tVillage tV INNER JOIN tland tl ON tV.villageId = tl.villageId";
|
||||
const cursor = "SELECT *".length;
|
||||
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
|
||||
|
||||
expect(
|
||||
buildSelectStarExpansion(
|
||||
context,
|
||||
new Map([
|
||||
[
|
||||
"tVillage",
|
||||
[
|
||||
{ name: "villageId", table: "tVillage" },
|
||||
{ name: "villageName", table: "tVillage" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"tland",
|
||||
[
|
||||
{ name: "villageId", table: "tland" },
|
||||
{ name: "landName", table: "tland" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
).toBe("tV.villageId, tV.villageName, tl.villageId, tl.landName");
|
||||
});
|
||||
|
||||
it("uses FROM/JOIN order even when the metadata map arrives in another order", () => {
|
||||
const sql = "SELECT * FROM tVillage tv INNER JOIN tland tl ON tv.villageId = tl.villageId";
|
||||
const cursor = "SELECT *".length;
|
||||
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
|
||||
|
||||
expect(
|
||||
buildSelectStarExpansion(
|
||||
context,
|
||||
new Map([
|
||||
[
|
||||
"tland",
|
||||
[
|
||||
{ name: "landName", table: "tland" },
|
||||
{ name: "villageId", table: "tland" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"tVillage",
|
||||
[
|
||||
{ name: "villageName", table: "tVillage" },
|
||||
{ name: "villageId", table: "tVillage" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
).toBe("tv.villageName, tv.villageId, tl.landName, tl.villageId");
|
||||
});
|
||||
|
||||
it("preserves an alias while replacing only the star", () => {
|
||||
const sql = "SELECT ap.* FROM apis AS ap";
|
||||
const cursor = "SELECT ap.*".length;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,69 @@ describe("sqlFormatter", () => {
|
|||
await expect(formatSqlText(`< 10 AND score > 2`, "postgres")).resolves.toBe(`< 10\nAND score > 2`);
|
||||
});
|
||||
|
||||
it("keeps logical conditions on one line when configured", async () => {
|
||||
const formatted = await formatSqlText("SELECT * FROM t WHERE a = 1 AND b = 2", "mysql", { logicalOperatorNewline: "none" });
|
||||
|
||||
expect(formatted).toContain("a = 1 AND b = 2");
|
||||
expect(formatted).not.toMatch(/\n\s*AND\b/i);
|
||||
});
|
||||
|
||||
it("does not collapse AND/OR line breaks inside block comments (regression: comment reformatting)", async () => {
|
||||
// sql-formatter preserves newlines inside /* ... */ verbatim, so the
|
||||
// keepLogicalOperatorsOnSameLine post-pass used to fold the comment's
|
||||
// internal `AND`/`OR` onto one line along with the real clause `AND`.
|
||||
// The comment body must stay multi-line; only the clause-level AND gets
|
||||
// pulled back onto the previous (comment-closing) line instead of sitting
|
||||
// alone on its own line.
|
||||
const sql = "SELECT * FROM t WHERE 1 = 1 /* note:\nAND is a keyword here\nOR also */ AND b = 2";
|
||||
|
||||
for (const dialect of ["mysql", "postgres", "generic"] as const) {
|
||||
const formatted = await formatSqlText(sql, dialect, { logicalOperatorNewline: "none" });
|
||||
|
||||
// 注释内部多行结构必须保留
|
||||
expect(formatted).toContain("/* note:");
|
||||
expect(formatted).toContain("AND is a keyword here");
|
||||
expect(formatted).toContain("OR also */");
|
||||
// 注释内部 AND/OR 仍各自独占一行(前面是换行)
|
||||
expect(formatted).toMatch(/note:\n\s*AND is a keyword here/);
|
||||
expect(formatted).toMatch(/keyword here\n\s*OR also/);
|
||||
// 真正子句间的 AND 换行应被折叠:AND b = 2 不再独占行首
|
||||
expect(formatted).toContain("*/ AND b = 2");
|
||||
expect(formatted).not.toMatch(/\n\s*AND b = 2/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not collapse AND/OR inside single-quoted string literals", async () => {
|
||||
// 字符串字面量内的 AND/OR 也不应被当作逻辑算子折叠。这里用一个含换行的
|
||||
// 字符串(虽然 sql-formatter 通常会把字符串单行化,但遮罩应防御性覆盖)。
|
||||
const sql = "SELECT 'a\nAND b\nOR c' AS s WHERE x = 1 AND y = 2";
|
||||
|
||||
const formatted = await formatSqlText(sql, "postgres", { logicalOperatorNewline: "none" });
|
||||
|
||||
expect(formatted).toContain("'a\nAND b\nOR c'");
|
||||
expect(formatted).toContain("x = 1 AND y = 2");
|
||||
});
|
||||
|
||||
it("can keep FROM and the first table on the same line", async () => {
|
||||
const formatted = await formatSqlText("SELECT * FROM tVillage AS tv INNER JOIN tLand AS tl ON tv.villageId = tl.villageId AND 1 = 1", "sqlserver", {
|
||||
fromClauseLayout: "sameLine",
|
||||
logicalOperatorNewline: "none",
|
||||
useTabs: true,
|
||||
tabWidth: 4,
|
||||
});
|
||||
|
||||
expect(formatted).toContain("FROM\ttVillage AS tv");
|
||||
expect(formatted).toContain("ON tv.villageId = tl.villageId AND 1 = 1");
|
||||
});
|
||||
|
||||
it("keeps derived tables multiline with FROM same-line layout", async () => {
|
||||
const formatted = await formatSqlText("SELECT * FROM (SELECT * FROM tVillage) AS tv", "sqlserver", { fromClauseLayout: "sameLine" });
|
||||
|
||||
expect(formatted).toContain("FROM\n");
|
||||
expect(formatted).toContain("\n SELECT");
|
||||
expect(formatted).toContain("FROM tVillage");
|
||||
});
|
||||
|
||||
it("keeps display formatting lossless for XML/JSON-looking input", async () => {
|
||||
const xml = `<root><item id="1">value</item></root>`;
|
||||
const json = `{"a":1}`;
|
||||
|
|
|
|||
|
|
@ -39,4 +39,20 @@ describe("sqlFormatterConfig shortcut storage", () => {
|
|||
custom: [{ regex: String.raw`\{\{[^}]+\}\}` }, { regex: String.raw`\$\{[^}]+\}` }, { regex: String.raw`#\{[^}]+\}` }],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the same-line logical operator mode", () => {
|
||||
const result = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { logicalOperatorNewline: "none" } }));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ ok: true }));
|
||||
if (result.ok) expect(result.settings.logicalOperatorNewline).toBe("none");
|
||||
expect(sqlFormatterOptions({ logicalOperatorNewline: "none" }).logicalOperatorNewline).toBe("before");
|
||||
});
|
||||
|
||||
it("accepts and serializes the FROM clause layout", () => {
|
||||
const result = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { fromClauseLayout: "sameLine" } }));
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ ok: true }));
|
||||
if (result.ok) expect(result.settings.fromClauseLayout).toBe("sameLine");
|
||||
expect(JSON.parse(serializeSqlFormatterConfig({ fromClauseLayout: "sameLine" })).options.fromClauseLayout).toBe("sameLine");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -973,6 +973,12 @@ export function buildSqlCompletionThemeRules(): CodeMirrorStyleSpec {
|
|||
color: colorMixValue("var(--amber-500, #f59e0b)", "color-mix(in oklch, var(--amber-500, #f59e0b) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(SCHEMA_ICON),
|
||||
},
|
||||
// Reuse the table glyph for aliases instead of CodeMirror's default text
|
||||
// icon, which is rendered as a solid black square in some themes.
|
||||
".cm-completionIcon-text": {
|
||||
color: colorMixValue("var(--violet-500, #8b5cf6)", "color-mix(in oklch, var(--violet-500, #8b5cf6) 92%, var(--popover-foreground))"),
|
||||
...lucideCompletionIconMask(TABLE_ICON),
|
||||
},
|
||||
".cm-completionLabel": {
|
||||
color: "inherit",
|
||||
flex: "0 1 auto",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type ShortcutActionId =
|
|||
| "acceptCompletion"
|
||||
| "indentMore"
|
||||
| "indentLess"
|
||||
| "insertLineBelow"
|
||||
| "duplicateLine"
|
||||
| "deleteLine"
|
||||
| "moveLineUp"
|
||||
|
|
@ -141,6 +142,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|||
scope: "editor",
|
||||
defaultShortcut: "Shift+Tab",
|
||||
},
|
||||
{
|
||||
id: "insertLineBelow",
|
||||
labelKey: "settings.shortcutInsertLineBelow",
|
||||
scope: "editor",
|
||||
defaultShortcut: "Shift+Enter",
|
||||
},
|
||||
{
|
||||
id: "duplicateLine",
|
||||
labelKey: "settings.shortcutDuplicateLine",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export type SqlCompletionItemType = "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "property" | "text";
|
||||
export type SqlCompletionItemType = "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "variable" | "property" | "text";
|
||||
|
||||
const COMPLETION_SPACE_BLOCKING_CHARACTERS = new Set([",", ";", ":", ")", "]", "}", "'", '"']);
|
||||
|
||||
export function appendSqlCompletionSpace(insertText: string, options: { enabled: boolean; itemType: SqlCompletionItemType; nextCharacter?: string }): string {
|
||||
if (!options.enabled || options.itemType === "property" || options.itemType === "text" || options.itemType === "schema" || options.itemType === "snippet" || options.itemType === "function") return insertText;
|
||||
if (!options.enabled || options.itemType === "property" || options.itemType === "text" || options.itemType === "schema" || options.itemType === "variable" || options.itemType === "snippet" || options.itemType === "function") return insertText;
|
||||
if (!insertText || /\s$/.test(insertText) || insertText.endsWith(".")) return insertText;
|
||||
|
||||
const nextCharacter = options.nextCharacter ?? "";
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
|||
{ id: "formatter-tab-width", category: "formatter", titleKey: "settings.sqlFormatterTabWidth", targetId: "formatter" },
|
||||
{ id: "formatter-indent-style", category: "formatter", titleKey: "settings.sqlFormatterIndentStyle", targetId: "formatter" },
|
||||
{ id: "formatter-logical-operator-newline", category: "formatter", titleKey: "settings.sqlFormatterLogicalOperatorNewline", targetId: "formatter" },
|
||||
{ id: "formatter-from-clause-layout", category: "formatter", titleKey: "settings.sqlFormatterFromClauseLayout", targetId: "formatter" },
|
||||
{ id: "formatter-expression-width", category: "formatter", titleKey: "settings.sqlFormatterExpressionWidth", targetId: "formatter" },
|
||||
{ id: "formatter-lines-between-queries", category: "formatter", titleKey: "settings.sqlFormatterLinesBetweenQueries", targetId: "formatter" },
|
||||
{ id: "formatter-dense-operators", category: "formatter", titleKey: "settings.sqlFormatterDenseOperators", targetId: "formatter" },
|
||||
|
|
|
|||
|
|
@ -23,10 +23,38 @@ function selectStarToken(model: SqlSemanticModel): SqlSemanticToken | undefined
|
|||
return model.tokens.find((token) => token.text === "*" && token.span.start === range.start && token.span.end === range.end);
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarTableSources(model: SqlSemanticModel): SqlSemanticRowSource[] {
|
||||
const star = selectStarToken(model);
|
||||
if (!star) return [];
|
||||
|
||||
if (model.cursorIntent.targetSourceId) {
|
||||
const target = model.rowSources.find((source) => source.id === model.cursorIntent.targetSourceId);
|
||||
return target?.kind === "table" ? [target] : [];
|
||||
}
|
||||
if (model.cursorIntent.qualifierParts.length > 0) return [];
|
||||
|
||||
let selectStart = -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") {
|
||||
selectStart = token.span.start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectStart < 0) return [];
|
||||
|
||||
const blockSources = model.rowSources.filter((source) => {
|
||||
if (source.sourceSpan.start < selectStart) return false;
|
||||
const sourceToken = model.tokens.find((token) => token.span.start === source.sourceSpan.start);
|
||||
return sourceToken?.depth === star.depth;
|
||||
});
|
||||
return blockSources.every((source) => source.kind === "table") ? blockSources : [];
|
||||
}
|
||||
|
||||
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;
|
||||
const sources = sqlSemanticSelectStarTableSources(model);
|
||||
return sources.length === 1 ? sources[0] : undefined;
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarQualifierSql(model: SqlSemanticModel): string | undefined {
|
||||
|
|
@ -80,6 +108,7 @@ export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlComplet
|
|||
schema: source.qualifierParts[source.qualifierParts.length - 1],
|
||||
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
|
||||
alias: source.alias,
|
||||
aliasSql: source.aliasSpan ? model.sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
|
||||
columns: source.columns,
|
||||
columnAliases: source.columnAliases,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,7 @@ export interface SqlCompletionColumn {
|
|||
name: string;
|
||||
table: string;
|
||||
sourceAlias?: string;
|
||||
sourceQualifierSql?: string;
|
||||
schema?: string;
|
||||
dataType?: string;
|
||||
isNullable?: boolean;
|
||||
|
|
@ -1201,7 +1202,7 @@ export interface SqlCompletionForeignKey {
|
|||
export interface SqlCompletionItem {
|
||||
label: string;
|
||||
filterText?: string;
|
||||
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema";
|
||||
type: "keyword" | "table" | "column" | "snippet" | "function" | "schema" | "variable" | "text";
|
||||
detail?: string;
|
||||
info?: string;
|
||||
apply?: string;
|
||||
|
|
@ -1223,6 +1224,7 @@ export interface SqlCompletionReferencedTable {
|
|||
schema?: string;
|
||||
schemaQuoted?: boolean;
|
||||
alias?: string;
|
||||
aliasSql?: string;
|
||||
columns?: string[];
|
||||
columnAliases?: string[];
|
||||
}
|
||||
|
|
@ -1292,6 +1294,7 @@ export interface SqlCompletionTranslations {
|
|||
numericLiteral: string;
|
||||
booleanValue: string;
|
||||
starExpansionColumns: string;
|
||||
tableAlias: string;
|
||||
functionDescriptions: Record<string, string>;
|
||||
}
|
||||
|
||||
|
|
@ -1393,6 +1396,10 @@ class SqlCompletionProvider {
|
|||
this.items.push(...buildPreferredKeywordItems(context.prefix, context.preferredKeywords, this.input.keywordCase));
|
||||
}
|
||||
|
||||
if (!pendingJoinKeyword && context.suggestColumns && !context.qualifier && !context.insertTable && context.prefix) {
|
||||
this.items.push(...buildReferencedAliasItems(context, this.t));
|
||||
}
|
||||
|
||||
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.prioritizeSelectAliases) {
|
||||
this.items.push(...buildSelectAliasItems(context));
|
||||
}
|
||||
|
|
@ -1456,8 +1463,8 @@ class SqlCompletionProvider {
|
|||
// Alias snippets reuse the prefix as a label while applying alias SQL, so they are not exact name matches.
|
||||
const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label, this.databaseType, this.input.keywordCase);
|
||||
const isExactLabelMatch = !isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase();
|
||||
const isExactSnippetPrefixMatch = item.type === "snippet" && item.filterText?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactSnippetPrefixMatch) {
|
||||
const isExactFilterTextMatch = item.filterText?.toLowerCase() === context.prefix.toLowerCase();
|
||||
if (isExactLabelMatch || isExactFilterTextMatch) {
|
||||
item.exactMatch = true;
|
||||
item.boost += EXACT_LABEL_MATCH_BOOST;
|
||||
}
|
||||
|
|
@ -3239,8 +3246,11 @@ function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordC
|
|||
}
|
||||
|
||||
function selectStarExpansionColumns(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): SqlCompletionColumn[] {
|
||||
const columns = context.qualifier ? referencedTablesForSelectAllColumns(context).flatMap((ref) => columnsForSelectAllReferencedTable(ref, columnsByTable)) : [...columnsByTable.values()].flat();
|
||||
return uniqueColumnsByName(columns);
|
||||
const references = referencedTablesForSelectAllColumns(context);
|
||||
if (context.qualifier || references.length > 1) {
|
||||
return references.flatMap((reference) => uniqueColumnsByName(columnsForSelectAllReferencedTable(reference, columnsByTable)));
|
||||
}
|
||||
return uniqueColumnsByName([...columnsByTable.values()].flat());
|
||||
}
|
||||
|
||||
export function selectStarResultColumnsMatch(options: { currentSql: string; targetFrom: number; targetTo: number; statementSql: string; sourceStatement?: string; sourceFrom?: number; sourceTo?: number }): boolean {
|
||||
|
|
@ -3259,7 +3269,17 @@ export function buildSelectStarExpansion(context: SqlCompletionContext, columnsB
|
|||
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.`.
|
||||
return qualifierSql ? buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType) : columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", ");
|
||||
if (qualifierSql) return buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType);
|
||||
|
||||
const references = referencedTablesForSelectAllColumns(context);
|
||||
if (references.length <= 1) return columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", ");
|
||||
|
||||
return references
|
||||
.flatMap((reference) => {
|
||||
const qualifier = reference.aliasSql ?? (reference.alias ? quoteSqlIdentifier(reference.alias, dialect) : quoteSqlIdentifier(reference.name, dialect));
|
||||
return uniqueColumnsByName(columnsForSelectAllReferencedTable(reference, columnsByTable)).map((column) => `${qualifier}.${quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)}`);
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null {
|
||||
|
|
@ -3480,6 +3500,27 @@ function buildComparisonValueItems(context: SqlCompletionContext, columnsByTable
|
|||
return items;
|
||||
}
|
||||
|
||||
function buildReferencedAliasItems(context: SqlCompletionContext, t?: SqlCompletionTranslations): SqlCompletionItem[] {
|
||||
const seen = new Set<string>();
|
||||
const items: SqlCompletionItem[] = [];
|
||||
for (const reference of context.referencedTables) {
|
||||
const alias = reference.alias?.trim();
|
||||
if (!alias || !matchesIdentifierSearch(alias, context.prefix)) continue;
|
||||
const key = normalizeIdentifierPart(alias);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const tableName = reference.schema ? `${reference.schema}.${reference.name}` : reference.name;
|
||||
items.push({
|
||||
label: alias,
|
||||
type: "text",
|
||||
detail: `${t?.tableAlias ?? "Table alias"} · ${tableName}`,
|
||||
apply: reference.aliasSql ?? alias,
|
||||
boost: 20_000 + identifierMatchScore(alias, context.prefix) - items.length,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
|
||||
const items: SqlCompletionItem[] = [];
|
||||
const existingAliases = new Set(context.referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias));
|
||||
|
|
@ -3673,23 +3714,28 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
|||
// Count name frequencies to detect duplicates across tables
|
||||
const nameCount = new Map<string, number>();
|
||||
for (const c of relevantCols) {
|
||||
nameCount.set(c.name, (nameCount.get(c.name) || 0) + 1);
|
||||
const nameKey = normalizeIdentifierPart(c.name);
|
||||
nameCount.set(nameKey, (nameCount.get(nameKey) || 0) + 1);
|
||||
}
|
||||
|
||||
// Deduplicate — for dupes, qualify with table name
|
||||
// Multi-source queries always insert a qualified column. Duplicate names remain
|
||||
// separate choices so the user can select the intended row source.
|
||||
const qualifyAllColumns = !context.qualifier && !context.insertTable && context.referencedTables.length > 1;
|
||||
const seen = new Set<string>();
|
||||
const uniqueColumns: Array<SqlCompletionColumn & { key: string; displayLabel: string }> = [];
|
||||
for (const c of relevantCols) {
|
||||
const count = nameCount.get(c.name) || 0;
|
||||
if (count > 1) {
|
||||
const qualifier = c.sourceAlias ?? c.table;
|
||||
const count = nameCount.get(normalizeIdentifierPart(c.name)) || 0;
|
||||
if (count > 1 || qualifyAllColumns) {
|
||||
const qualifier = c.sourceQualifierSql ?? c.sourceAlias ?? c.table;
|
||||
const qualifiedKey = `${qualifier}.${c.name}`;
|
||||
if (seen.has(qualifiedKey)) continue;
|
||||
seen.add(qualifiedKey);
|
||||
const normalizedQualifiedKey = normalizeCompletionKey(qualifiedKey);
|
||||
if (seen.has(normalizedQualifiedKey)) continue;
|
||||
seen.add(normalizedQualifiedKey);
|
||||
uniqueColumns.push({ ...c, key: c.key, displayLabel: `${qualifier}.${c.name}` });
|
||||
} else {
|
||||
if (seen.has(c.name)) continue;
|
||||
seen.add(c.name);
|
||||
const nameKey = normalizeIdentifierPart(c.name);
|
||||
if (seen.has(nameKey)) continue;
|
||||
seen.add(nameKey);
|
||||
uniqueColumns.push({ ...c, key: c.key, displayLabel: c.name });
|
||||
}
|
||||
}
|
||||
|
|
@ -3707,6 +3753,7 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
|
|||
const matchScore = Math.max(identifierMatchScore(column.name, context.prefix), identifierMatchScore(column.displayLabel, context.prefix));
|
||||
return {
|
||||
label: column.displayLabel,
|
||||
filterText: column.displayLabel === column.name ? undefined : column.name,
|
||||
type: "column" as const,
|
||||
detail: buildColumnDetail(column),
|
||||
info: buildColumnInfo(column),
|
||||
|
|
@ -3721,7 +3768,7 @@ function completionColumnsForReferencedTable<T extends SqlCompletionColumn & { k
|
|||
const matched = columns.filter((column) => columnMatchesReferencedTable(column, table));
|
||||
const aliasedColumns = applyReferencedColumnAliases(table, matched);
|
||||
if (!table.alias) return aliasedColumns;
|
||||
return aliasedColumns.map((column) => ({ ...column, sourceAlias: table.alias }));
|
||||
return aliasedColumns.map((column) => ({ ...column, sourceAlias: table.alias, sourceQualifierSql: table.aliasSql }));
|
||||
}
|
||||
|
||||
function applyReferencedColumnAliases<T extends SqlCompletionColumn>(table: SqlCompletionReferencedTable, columns: readonly T[]): T[] {
|
||||
|
|
@ -3781,7 +3828,8 @@ function buildColumnApply(column: SqlCompletionColumn & { displayLabel: string }
|
|||
if (context.qualifier || column.displayLabel === column.name || !column.displayLabel.includes(".")) {
|
||||
return quoteSqlIdentifier(column.name, dialect);
|
||||
}
|
||||
return `${quoteSqlIdentifier(column.sourceAlias ?? column.table, dialect)}.${quoteSqlIdentifier(column.name, dialect)}`;
|
||||
const qualifier = column.sourceQualifierSql ?? quoteSqlIdentifier(column.sourceAlias ?? column.table, dialect);
|
||||
return `${qualifier}.${quoteSqlIdentifier(column.name, dialect)}`;
|
||||
}
|
||||
|
||||
function isKeyColumn(name: string): boolean {
|
||||
|
|
@ -4515,6 +4563,7 @@ function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] {
|
|||
}
|
||||
|
||||
function compareCompletionItems(left: SqlCompletionItem, right: SqlCompletionItem): number {
|
||||
if ((left.type === "variable") !== (right.type === "variable")) return left.type === "variable" ? -1 : 1;
|
||||
if (!left.exactMatch !== !right.exactMatch) return left.exactMatch ? -1 : 1;
|
||||
const leftBonus = getHistoryBoost(left.label, left.type);
|
||||
const rightBonus = getHistoryBoost(right.label, right.type);
|
||||
|
|
@ -4529,6 +4578,10 @@ function getTypePriorityBoost(type: SqlCompletionItem["type"]): number {
|
|||
return 160;
|
||||
case "schema":
|
||||
return 120;
|
||||
case "variable":
|
||||
return 220;
|
||||
case "text":
|
||||
return 220;
|
||||
case "function":
|
||||
return 90;
|
||||
case "snippet":
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DEFAULT_SQL_FORMATTER_SETTINGS, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { looksLikeXml } from "@/lib/sql/autoFormat";
|
||||
|
||||
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "clickhouse" | "generic";
|
||||
|
|
@ -86,10 +86,12 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
|||
}
|
||||
|
||||
const { format } = await import("sql-formatter");
|
||||
const options = sqlFormatterOptions(settings);
|
||||
const normalizedSettings = normalizeSqlFormatterSettings(settings);
|
||||
const options = sqlFormatterOptions(normalizedSettings);
|
||||
const language = formatterLanguage(dialect);
|
||||
try {
|
||||
return format(sql, { language, ...options });
|
||||
const formatted = format(sql, { language, ...options });
|
||||
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
|
||||
} catch (err) {
|
||||
// The generic "sql" dialect can't parse many real-world constructs (PostgreSQL
|
||||
// `::` casts, GaussDB/openGauss materialized-view DDL, T-SQL specifics, ...).
|
||||
|
|
@ -97,7 +99,8 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
|||
// that tolerates most of these, before surfacing the failure.
|
||||
if (language !== "postgresql") {
|
||||
try {
|
||||
return format(sql, { language: "postgresql", ...options });
|
||||
const formatted = format(sql, { language: "postgresql", ...options });
|
||||
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
|
||||
} catch {
|
||||
// fall through to the original error below
|
||||
}
|
||||
|
|
@ -106,6 +109,236 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces block comments, line comments and string/identifier literals in
|
||||
* `sql` with opaque placeholders, returning the masked text plus the captured
|
||||
* spans in source order. The two regexes in {@link keepLogicalOperatorsOnSameLine}
|
||||
* operate on plain text and cannot tell a logical operator inside a comment or
|
||||
* string literal from one in a real SQL clause, so a multi-line `/* ... AND ...
|
||||
* OR ... *\/` comment would get its internal line breaks collapsed. Masking
|
||||
* those spans first keeps the regexes away from them; {@link restoreSpans} puts
|
||||
* the original text back afterwards.
|
||||
*
|
||||
* The scanner reuses the same dialect-aware token recognition as
|
||||
* {@link compressSqlText}: block comments (with nesting for Postgres/SQL
|
||||
* Server/ClickHouse), `--`/`#` line comments, Postgres dollar-quoted strings,
|
||||
* single-quoted strings (`''` and MySQL/`E'...'` backslash escapes), double-
|
||||
* quoted identifiers (`""` escapes), MySQL backtick identifiers and SQL Server
|
||||
* `[...]` identifiers. sql-formatter normalizes string literals to a single
|
||||
* line, but masking them is cheap and defends against callers that ever pass
|
||||
* raw (non-formatter) SQL through this path.
|
||||
*/
|
||||
function maskStringAndCommentSpans(sql: string, dialect: SqlFormatDialect): { masked: string; spans: string[] } {
|
||||
const len = sql.length;
|
||||
const spans: string[] = [];
|
||||
const placeholder = (index: number) => `\x00${index}\x00`;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
|
||||
const isIdentifierPart = (c: string | undefined) => c !== undefined && /[A-Za-z0-9_$]/.test(c);
|
||||
const supportsNestedBlockComments = dialect === "postgres" || dialect === "sqlserver" || dialect === "clickhouse";
|
||||
const isMysqlDashComment = (c: string | undefined) => c === undefined || c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127;
|
||||
|
||||
const dollarQuoteTagAt = (position: number): string | null => {
|
||||
if (sql[position] !== "$" || isIdentifierPart(sql[position - 1])) return null;
|
||||
if (sql[position + 1] === "$") return "$$";
|
||||
if (!/[A-Za-z_]/.test(sql[position + 1] ?? "")) return null;
|
||||
let end = position + 2;
|
||||
while (/[A-Za-z0-9_]/.test(sql[end] ?? "")) end++;
|
||||
return sql[end] === "$" ? sql.slice(position, end + 1) : null;
|
||||
};
|
||||
|
||||
// Captures a full span starting at `start` (already consumed into `i`) and
|
||||
// emits a placeholder. `end` is the index just past the span terminator.
|
||||
const emit = (start: number, end: number) => {
|
||||
spans.push(sql.slice(start, end));
|
||||
out += placeholder(spans.length - 1);
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
const ch = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
// 块注释 /* ... */(含嵌套)
|
||||
if (ch === "/" && next === "*") {
|
||||
const start = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < len && depth > 0) {
|
||||
if (supportsNestedBlockComments && sql[i] === "/" && sql[i + 1] === "*") {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (sql[i] === "*" && sql[i + 1] === "/") {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// 未闭合的块注释:原样保留剩余文本(不遮罩),避免破坏用户输入。
|
||||
if (depth > 0) {
|
||||
out += sql.slice(start);
|
||||
i = len;
|
||||
} else {
|
||||
emit(start, i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 行注释 -- ... / # ...(不跨行,但遮罩可防御未来变更)
|
||||
const startsDashComment = ch === "-" && next === "-" && (dialect !== "mysql" || isMysqlDashComment(sql[i + 2]));
|
||||
if (startsDashComment || (dialect === "mysql" && ch === "#")) {
|
||||
const start = i;
|
||||
i += startsDashComment ? 2 : 1;
|
||||
while (i < len && sql[i] !== "\n" && sql[i] !== "\r") i++;
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PostgreSQL dollar-quoted 字符串
|
||||
if (dialect === "postgres" && ch === "$") {
|
||||
const tag = dollarQuoteTagAt(i);
|
||||
if (tag) {
|
||||
const start = i;
|
||||
i += tag.length;
|
||||
const end = sql.indexOf(tag, i);
|
||||
if (end < 0) {
|
||||
out += sql.slice(start);
|
||||
i = len;
|
||||
} else {
|
||||
emit(start, end + tag.length);
|
||||
i = end + tag.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 单引号字符串('' 转义;MySQL/PG E'...' 反斜杠转义)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
const postgresEscapeString = dialect === "postgres" && (sql[i - 2] === "E" || sql[i - 2] === "e") && !isIdentifierPart(sql[i - 3]);
|
||||
while (i < len) {
|
||||
const c = sql[i];
|
||||
if ((dialect === "mysql" || postgresEscapeString) && c === "\\" && i + 1 < len) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
if (sql[i + 1] === "'") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 双引号标识符("" 转义)
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (dialect === "mysql" && sql[i] === "\\" && i + 1 < len) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[i] === '"') {
|
||||
if (sql[i + 1] === '"') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 反引号标识符(MySQL)
|
||||
if (ch === "`") {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len && sql[i] !== "`") i++;
|
||||
if (i < len) i++;
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL Server 方括号标识符 [...](]] 为转义 ])
|
||||
if (dialect === "sqlserver" && ch === "[") {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (sql[i] === "]") {
|
||||
if (sql[i + 1] === "]") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
emit(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
return { masked: out, spans };
|
||||
}
|
||||
|
||||
function restoreSpans(masked: string, spans: string[]): string {
|
||||
return masked.replace(/\x00(\d+)\x00/g, (_, index) => spans[Number(index)] ?? "");
|
||||
}
|
||||
|
||||
function keepLogicalOperatorsOnSameLine(sql: string, dialect: SqlFormatDialect = "generic"): string {
|
||||
// 先遮罩块注释/字符串/引号标识符,避免正则命中注释或字面量内部的 AND/OR/XOR
|
||||
// (块注释内部跨行的 AND/OR 会被误折叠成空格,破坏用户多行注释格式)。
|
||||
const { masked, spans } = maskStringAndCommentSpans(sql, dialect);
|
||||
const collapsed = masked.replace(/\n[ \t]*(AND|OR|XOR)\b/gi, " $1").replace(/\b(AND|OR|XOR)[ \t]*\n[ \t]*/gi, "$1 ");
|
||||
return restoreSpans(collapsed, spans);
|
||||
}
|
||||
|
||||
function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
|
||||
const lines = sql.split("\n");
|
||||
for (let index = 0; index < lines.length - 1; index += 1) {
|
||||
const clauseMatch = lines[index].match(/^(\s*)FROM\s*$/i);
|
||||
if (!clauseMatch) continue;
|
||||
|
||||
const sourceLine = lines[index + 1];
|
||||
const sourceMatch = sourceLine.match(/^(\s+)(\S.*)$/);
|
||||
if (!sourceMatch) continue;
|
||||
const source = sourceMatch[2];
|
||||
// Keep derived tables and leading comments multiline; merging these would
|
||||
// make nested SQL and comment boundaries substantially harder to read.
|
||||
if (source.startsWith("(") || source.startsWith("/*") || source.startsWith("--")) continue;
|
||||
|
||||
const clauseIndent = clauseMatch[1];
|
||||
const sourceIndent = sourceMatch[1];
|
||||
const separator = sourceIndent.startsWith(clauseIndent) ? sourceIndent.slice(clauseIndent.length) : " ";
|
||||
lines[index] = `${lines[index]}${separator || " "}${source}`;
|
||||
lines.splice(index + 1, 1);
|
||||
index -= 1;
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
|
||||
let formatted = settings.logicalOperatorNewline === "none" ? keepLogicalOperatorsOnSameLine(sql, dialect) : sql;
|
||||
if (settings.fromClauseLayout === "sameLine") formatted = keepFromClauseAndFirstSourceOnSameLine(formatted);
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function isSqlFormatterParseError(error: unknown): boolean {
|
||||
if (!(error instanceof Error) || !error.message.startsWith("Parse error at token:")) return false;
|
||||
const candidate = error as Error & { offset?: unknown; token?: unknown };
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ export const SQL_FORMATTER_CONFIG_FORMATTER = "sql-formatter";
|
|||
|
||||
const CASE_VALUES = ["preserve", "upper", "lower"] as const;
|
||||
const INDENT_STYLE_VALUES = ["standard", "tabularLeft", "tabularRight"] as const;
|
||||
const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after"] as const;
|
||||
const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after", "none"] as const;
|
||||
const FROM_CLAUSE_LAYOUT_VALUES = ["newLine", "sameLine"] as const;
|
||||
const TAB_WIDTH_VALUES = [2, 4] as const;
|
||||
const EXPRESSION_WIDTH_VALUES = [50, 80, 120] as const;
|
||||
const LINES_BETWEEN_QUERIES_VALUES = [0, 1, 2] as const;
|
||||
|
|
@ -14,6 +15,7 @@ const SQL_FORMATTER_LEGACY_OPTION_KEYS = new Set(["params"]);
|
|||
export type SqlFormatterCase = (typeof CASE_VALUES)[number];
|
||||
export type SqlFormatterIndentStyle = (typeof INDENT_STYLE_VALUES)[number];
|
||||
export type SqlFormatterLogicalOperatorNewline = (typeof LOGICAL_OPERATOR_NEWLINE_VALUES)[number];
|
||||
export type SqlFormatterFromClauseLayout = (typeof FROM_CLAUSE_LAYOUT_VALUES)[number];
|
||||
export type SqlFormatterTabWidth = (typeof TAB_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterExpressionWidth = (typeof EXPRESSION_WIDTH_VALUES)[number];
|
||||
export type SqlFormatterLinesBetweenQueries = (typeof LINES_BETWEEN_QUERIES_VALUES)[number];
|
||||
|
|
@ -44,6 +46,7 @@ export interface SqlFormatterOptionSettings {
|
|||
useTabs: boolean;
|
||||
tabWidth: SqlFormatterTabWidth;
|
||||
logicalOperatorNewline: SqlFormatterLogicalOperatorNewline;
|
||||
fromClauseLayout: SqlFormatterFromClauseLayout;
|
||||
expressionWidth: SqlFormatterExpressionWidth;
|
||||
linesBetweenQueries: SqlFormatterLinesBetweenQueries;
|
||||
denseOperators: boolean;
|
||||
|
|
@ -70,6 +73,7 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
|
|||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
logicalOperatorNewline: "before",
|
||||
fromClauseLayout: "newLine",
|
||||
expressionWidth: 50,
|
||||
linesBetweenQueries: 1,
|
||||
denseOperators: false,
|
||||
|
|
@ -86,6 +90,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
|
|||
"useTabs",
|
||||
"tabWidth",
|
||||
"logicalOperatorNewline",
|
||||
"fromClauseLayout",
|
||||
"expressionWidth",
|
||||
"linesBetweenQueries",
|
||||
"denseOperators",
|
||||
|
|
@ -102,6 +107,7 @@ const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings,
|
|||
useTabs: (value) => typeof value === "boolean",
|
||||
tabWidth: (value) => isNumberChoice(value, TAB_WIDTH_VALUES),
|
||||
logicalOperatorNewline: (value) => isStringChoice(value, LOGICAL_OPERATOR_NEWLINE_VALUES),
|
||||
fromClauseLayout: (value) => isStringChoice(value, FROM_CLAUSE_LAYOUT_VALUES),
|
||||
expressionWidth: (value) => isNumberChoice(value, EXPRESSION_WIDTH_VALUES),
|
||||
linesBetweenQueries: (value) => isNumberChoice(value, LINES_BETWEEN_QUERIES_VALUES),
|
||||
denseOperators: (value) => typeof value === "boolean",
|
||||
|
|
@ -182,6 +188,7 @@ export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptio
|
|||
useTabs: normalizeBoolean(input.useTabs, DEFAULT_SQL_FORMATTER_SETTINGS.useTabs),
|
||||
tabWidth: normalizeNumberChoice(input.tabWidth, TAB_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.tabWidth),
|
||||
logicalOperatorNewline: normalizeChoice(input.logicalOperatorNewline, LOGICAL_OPERATOR_NEWLINE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.logicalOperatorNewline),
|
||||
fromClauseLayout: normalizeChoice(input.fromClauseLayout, FROM_CLAUSE_LAYOUT_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.fromClauseLayout),
|
||||
expressionWidth: normalizeNumberChoice(input.expressionWidth, EXPRESSION_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.expressionWidth),
|
||||
linesBetweenQueries: normalizeNumberChoice(input.linesBetweenQueries, LINES_BETWEEN_QUERIES_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.linesBetweenQueries),
|
||||
denseOperators: normalizeBoolean(input.denseOperators, DEFAULT_SQL_FORMATTER_SETTINGS.denseOperators),
|
||||
|
|
@ -253,7 +260,9 @@ export function sqlFormatterOptions(settings: unknown) {
|
|||
indentStyle: normalized.indentStyle,
|
||||
useTabs: normalized.useTabs,
|
||||
tabWidth: normalized.tabWidth,
|
||||
logicalOperatorNewline: normalized.logicalOperatorNewline,
|
||||
// sql-formatter itself only supports before/after. The custom "none"
|
||||
// mode is applied as a post-processing pass by formatSqlText.
|
||||
logicalOperatorNewline: normalized.logicalOperatorNewline === "none" ? "before" : normalized.logicalOperatorNewline,
|
||||
expressionWidth: normalized.expressionWidth,
|
||||
linesBetweenQueries: normalized.linesBetweenQueries,
|
||||
denseOperators: normalized.denseOperators,
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,7 @@ async fn postgres_query_one_cached(
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum PreparedSelectOutcome {
|
||||
Complete(Box<QueryResult>),
|
||||
TextFallback { column_types: Vec<String>, unsupported_type: String },
|
||||
|
|
|
|||
|
|
@ -2063,6 +2063,8 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
|
|||
.filter(|schema| !schema.trim().is_empty())
|
||||
.map(|schema| format!(" AND s.name = '{}' ", schema.replace('\'', "''")))
|
||||
.unwrap_or_default();
|
||||
let columns_only = object_kinds.len() == 1
|
||||
&& matches!(object_kinds.first(), Some(crate::types::CompletionAssistantObjectKind::Column));
|
||||
|
||||
let mut queries = Vec::new();
|
||||
if (mask.starts_with('#') || mask.starts_with("%#"))
|
||||
|
|
@ -2145,12 +2147,15 @@ fn sqlserver_completion_assistant_sql(request: &crate::types::CompletionAssistan
|
|||
FROM sys.columns c \
|
||||
JOIN sys.objects o ON o.object_id = c.object_id \
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id \
|
||||
WHERE o.type IN ('U','V') AND {object_visibility} {schema_filter} {parent_table_filter} {column_like}"
|
||||
WHERE o.type IN ('U','V') AND {object_visibility} {schema_filter} {parent_table_filter} {column_like} ORDER BY c.column_id"
|
||||
));
|
||||
}
|
||||
|
||||
if queries.is_empty() {
|
||||
"SELECT TOP (0) CAST('' AS NVARCHAR(128)) AS name, CAST('' AS NVARCHAR(128)) AS schema_name, CAST('' AS NVARCHAR(60)) AS object_type, CAST(NULL AS NVARCHAR(128)) AS parent_schema, CAST(NULL AS NVARCHAR(128)) AS parent_name, CAST(NULL AS NVARCHAR(MAX)) AS object_comment, CAST(NULL AS NVARCHAR(128)) AS data_type".to_string()
|
||||
} else if columns_only && queries.len() == 1 {
|
||||
// Preserve sys.columns.column_id order for SELECT * expansion.
|
||||
queries.remove(0)
|
||||
} else if queries.len() == 1 {
|
||||
format!("SELECT * FROM ({}) AS dbx_completion ORDER BY name", queries.remove(0))
|
||||
} else {
|
||||
|
|
@ -4148,6 +4153,8 @@ mod tests {
|
|||
assert!(sql.contains("o.name = 'Users'"));
|
||||
assert!(sql.contains("LOWER(c.name) LIKE LOWER('%id%') ESCAPE '\\'"));
|
||||
assert!(sql.contains("CAST(NULL AS NVARCHAR(MAX)) AS object_comment"));
|
||||
assert!(sql.contains("ORDER BY c.column_id"));
|
||||
assert!(!sql.contains("AS dbx_completion ORDER BY name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -847,7 +847,7 @@ test("suggests columns from referenced tables in select list", () => {
|
|||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.equal(items[0]?.label, "name");
|
||||
assert.equal(items[0]?.label, "u.name");
|
||||
assert.equal(items[0]?.type, "column");
|
||||
});
|
||||
|
||||
|
|
@ -2598,7 +2598,7 @@ test("filters data type keywords out of SELECT context", () => {
|
|||
|
||||
// --- Qualified column names for duplicates ---
|
||||
|
||||
test("uses row-source aliases when multiple tables share column names", () => {
|
||||
test("uses row-source aliases for all columns across multiple tables", () => {
|
||||
const sql = "select from public.users u join public.orders o on u.id = o.user_id";
|
||||
const items = buildSqlCompletionItems(sql, "select ".length, {
|
||||
tables,
|
||||
|
|
@ -2613,13 +2613,10 @@ test("uses row-source aliases when multiple tables share column names", () => {
|
|||
columns.some((item) => item.label === "o.id" && item.apply === "o.id"),
|
||||
"should show o.id",
|
||||
);
|
||||
assert.ok(columns.some((item) => item.label === "u.name" && item.apply === "u.name"), "unique name should use its row-source alias");
|
||||
assert.ok(
|
||||
columns.some((item) => item.label === "name"),
|
||||
"unique name should remain unqualified",
|
||||
);
|
||||
assert.ok(
|
||||
columns.some((item) => item.label === "user_id"),
|
||||
"unique user_id should remain unqualified",
|
||||
columns.some((item) => item.label === "o.user_id" && item.apply === "o.user_id"),
|
||||
"unique user_id should use its row-source alias",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue