add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息 (#122)

* add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息

* add: 修复联表情况下获取列信息的问题

* fix: 修复联表场景下的SQL语句提示

* remove: 移除不需要的调试打印

* add: 补充'列详情'多语言字段

* remove: 移除不必要的函数

* remove: 移除不必要的导入

* add: 目前会分析SQL语句内引用的表,并且只加载对应的表内的列信息,不再对全表做列信息拉取
This commit is contained in:
rarnu
2026-05-07 01:04:52 +08:00
committed by GitHub
parent 4bf4f3b77f
commit 969f441753
8 changed files with 863 additions and 37 deletions
+22
View File
@@ -227,6 +227,27 @@ async function openConnectionQuery(connectionId: string) {
}
}
async function onClickTable(tableName: string) {
const tab = activeTab.value;
if (!tab) return;
const connectionId = tab.connectionId;
const database = tab.database;
// Parse schema.table if needed
const [schema, rawTableName] = tableName.includes(".") ? tableName.split(".") : [database, tableName];
try {
await connectionStore.ensureConnected(connectionId);
const ddl = await api.getTableDdl(connectionId, database, schema || database, rawTableName);
// Create a new tab with the DDL
const tabId = queryStore.createTab(connectionId, database, `DDL - ${rawTableName}`);
queryStore.updateSql(tabId, ddl);
} catch (e: any) {
toast(`Failed to get table DDL: ${e?.message || String(e)}`, 5000);
}
}
async function changeActiveConnection(connectionId: string) {
const tab = activeTab.value;
if (!tab) return;
@@ -458,6 +479,7 @@ onUnmounted(() => {
@paginate="onPaginate"
@sort="onSort"
@execute-sql="onExecuteSql"
@click-table="onClickTable"
/>
</div>
<WelcomeScreen
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { X, Copy } from "lucide-vue-next";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export interface ColumnInfo {
name: string;
table: string;
dataType?: string;
isNullable?: boolean;
columnDefault?: string | null;
isPrimaryKey?: boolean;
comment?: string | null;
extra?: string | null;
}
defineProps<{
columns: ColumnInfo[];
loading: boolean;
error?: string;
}>();
const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
function copyText(text: string) {
navigator.clipboard.writeText(text);
}
</script>
<template>
<div class="absolute top-0 right-0 bottom-0 z-10 w-72 bg-card border-l border-border shadow-lg flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
<span class="text-sm font-medium">{{ t("grid.columnDetails") }}</span>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="emit('close')">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<template v-if="loading">
<div class="text-xs text-muted-foreground animate-pulse">{{ t("common.loading") }}</div>
</template>
<template v-else-if="error">
<div class="text-xs text-destructive">{{ error }}</div>
</template>
<template v-else>
<!-- Column detail sections -->
<div v-for="(col, idx) in columns" :key="idx" class="space-y-2">
<!-- Column header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-semibold text-sm text-foreground">{{ col.name }}</span>
<Badge
v-if="col.isPrimaryKey"
variant="outline"
class="h-4 px-1 text-[10px] border-primary/40 text-primary"
>
PK
</Badge>
</div>
<Button variant="ghost" size="sm" class="h-5 w-5 p-0" @click="copyText(col.name)">
<Copy class="h-3 w-3" />
</Button>
</div>
<!-- Table origin -->
<div class="text-xs text-muted-foreground">{{ t("tabs.tooltipTable") }} {{ col.table }}</div>
<!-- Detail rows -->
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span class="font-medium max-w-[180px] truncate" :title="col.dataType">
{{ col.dataType || "—" }}
</span>
</div>
<div class="flex items-center justify-between px-2 py-1">
<span class="text-muted-foreground">{{ t("structureEditor.nullable") }}</span>
<span class="font-medium">{{ col.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
</div>
<div v-if="col.columnDefault" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.defaultValue") }}</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.columnDefault">
{{ col.columnDefault }}
</span>
</div>
<div v-if="col.comment" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.comment") }}</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.comment">
{{ col.comment }}
</span>
</div>
<div v-if="col.extra" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">Extra</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.extra">
{{ col.extra }}
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
+208 -18
View File
@@ -7,7 +7,9 @@ import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext } from "@/lib/sqlCompletion";
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import type { SqlCompletionColumn } from "@/lib/sqlCompletion";
const props = defineProps<{
modelValue: string;
@@ -24,6 +26,9 @@ const emit = defineEmits<{
cursorChange: [pos: number];
formatError: [message: string];
execute: [sql: string];
clickTable: [tableName: string];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
}>();
const editorRef = ref<HTMLDivElement>();
@@ -36,6 +41,11 @@ let editorViewModule: typeof import("@codemirror/view") | null = null;
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
function setFontSize(size: number) {
const next = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size));
const ss = settingsStore.editorSettings;
@@ -104,30 +114,57 @@ async function formatCurrentSql() {
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number) {
if (!props.connectionId || !props.database) return null;
const completionContext = getSqlCompletionContext(currentState.doc.toString(), position);
const fullDoc = currentState.doc.toString();
const completionContext = getSqlCompletionContext(fullDoc, position);
const tables = await connectionStore.listCompletionTables(props.connectionId, props.database);
const columnsByTable = new Map<string, Awaited<ReturnType<typeof connectionStore.listCompletionColumns>>>();
if (completionContext.suggestColumns) {
const relatedTables = completionContext.qualifier
? completionContext.referencedTables.filter(
(table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier,
)
: completionContext.referencedTables;
// Collect referenced tables — enrich with schema from cached tables list
let refs = completionContext.referencedTables.map((rt) => {
// If no schema, look it up in the cached tables
if (!rt.schema) {
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema) {
return { ...rt, schema: cached.schema };
}
}
return rt;
});
await Promise.all(
relatedTables.map(async (table) => {
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
if (columnsByTable.has(cacheKey)) return;
// If no referenced tables but qualifier exists, infer table from tables list
if (refs.length === 0 && completionContext.qualifier) {
const q = completionContext.qualifier.toLowerCase();
const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q));
refs = matched.map((t) => ({ name: t.name, schema: t.schema }));
}
await Promise.all(
refs.map(async (refTable) => {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
if (cachedColumnsByTable.has(cacheKey)) {
return;
}
try {
const columns = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
table.name,
table.schema,
refTable.name,
refTable.schema,
);
columnsByTable.set(cacheKey, columns);
}),
);
cachedColumnsByTable.set(cacheKey, columns);
} catch (e) {
console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e);
}
}),
);
// Build columnsByTable from persistent cache — only include columns for referenced tables
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
for (const refTable of refs) {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
const cached = cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
}
}
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -149,6 +186,17 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
};
}
async function refreshCompletionCache() {
if (!props.connectionId || !props.database) return;
try {
cachedTables = await connectionStore.listCompletionTables(props.connectionId, props.database);
} catch {
cachedTables = [];
}
// Clear column cache when connection/database changes
cachedColumnsByTable.clear();
}
onMounted(async () => {
if (!editorRef.value) return;
@@ -260,11 +308,139 @@ onMounted(async () => {
else if (event.deltaY > 0) zoomOut();
return true;
},
mousedown: (event: MouseEvent) => {
// Click without modifier -> close column panel
if (!event.metaKey && !event.ctrlKey) {
if (event.button === 0) {
emit("closeColumnPanel");
}
return false;
}
// Only handle Ctrl/Cmd + left click
if (event.button !== 0) return false;
const currentView = view.value;
if (!currentView || !props.connectionId || !props.database) {
return false;
}
// Use posAtCoords for accurate click position
const coords = { x: event.clientX, y: event.clientY };
const pos = currentView.posAtCoords(coords);
if (pos == null) {
return false;
}
const doc = currentView.state.doc.toString();
const identifier = extractIdentifierAt(doc, pos);
if (!identifier) {
return false;
}
if (isSqlKeyword(identifier)) {
return false;
}
// Prevent default, resolve async
event.preventDefault();
setTimeout(async () => {
try {
// Ensure table cache is populated
if (cachedTables.length === 0) {
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!);
}
// 1. Check if it's a table name
const matchedTable = matchTable(identifier, cachedTables);
if (matchedTable) {
emit(
"clickTable",
matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name,
);
return;
}
// 2. Parse SQL at click position to get referenced tables
const context = getSqlCompletionContext(doc, pos);
let referencedTables = context.referencedTables;
// Enrich referenced tables with schema from cachedTables
referencedTables = referencedTables.map((rt) => {
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema && !rt.schema) {
return { ...rt, schema: cached.schema };
}
return rt;
});
// Check if identifier has a qualifier (e.g., c.card_name)
const qualifierMatch = /^(.+)\.(.+)$/.exec(identifier);
const qualifier = qualifierMatch ? qualifierMatch[1] : null;
const colName = qualifierMatch ? qualifierMatch[2] : identifier;
const colLower = colName.toLowerCase();
if (referencedTables.length === 0) {
return;
}
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
const tablesToCheck = qualifier
? referencedTables.filter(
(rt) =>
rt.alias?.toLowerCase() === qualifier.toLowerCase() ||
rt.name.toLowerCase() === qualifier.toLowerCase(),
)
: referencedTables;
if (tablesToCheck.length === 0 && qualifier) {
return;
}
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
for (const refTable of tablesToCheck) {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
// Use persistent column cache; fetch only if missing
let cols = cachedColumnsByTable.get(cacheKey);
if (!cols) {
try {
cols = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
refTable.name,
refTable.schema,
);
cachedColumnsByTable.set(cacheKey, cols);
} catch {
continue;
}
}
for (const col of cols) {
if (col.name.toLowerCase() === colLower) {
matchedCols.push({
name: col.name,
table: refTable.name,
schema: col.schema || refTable.schema,
});
}
}
}
if (matchedCols.length > 0) {
emit("clickColumn", matchedCols);
}
} catch (e) {
console.error("[DBX] Ctrl+click error:", e);
}
}, 0);
return true;
},
}),
],
});
view.value = new EditorView({ state, parent: editorRef.value });
// Fetch completion data
refreshCompletionCache();
});
watch(
@@ -285,7 +461,21 @@ watch(
},
);
// Reactively apply editor settings changes (theme, font family, font size)
watch(
() => props.connectionId,
() => {
refreshCompletionCache();
},
);
watch(
() => props.database,
() => {
refreshCompletionCache();
},
);
// Reactively apply editor settings changes
watch(
() => settingsStore.editorSettings,
async (ss) => {
+94 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import { computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3 } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
@@ -9,6 +9,8 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -41,11 +43,18 @@ const emit = defineEmits<{
paginate: [offset: number, limit: number, whereInput?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
executeSql: [sql: string];
clickTable: [tableName: string];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
// Column info panel state
const showColumnInfo = ref(false);
const columnInfoColumns = ref<ColumnInfo[]>([]);
const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
switch (props.activeConnection?.db_type) {
case "mysql":
@@ -70,6 +79,79 @@ const hasNumericData = computed(() => {
if (!r || r.rows.length === 0) return false;
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
});
// Column info panel handlers
async function onHandleClickColumn(
matchedCols: Array<{ name: string; table: string; schema?: string }>,
errorMsg?: string,
) {
if (!props.activeTab.connectionId || !props.activeTab.database) return;
// If error or no columns, silently ignore — don't show the panel
if (errorMsg || matchedCols.length === 0) return;
columnInfoLoading.value = true;
columnInfoError.value = undefined;
try {
// Fetch full column details from API
const apiModule = await import("@/lib/api");
const results: ColumnInfo[] = [];
for (const matchedCol of matchedCols) {
const querySchema = matchedCol.schema || props.activeTab.database || "";
try {
const fullColumns = await apiModule.getColumns(
props.activeTab.connectionId,
props.activeTab.database,
querySchema,
matchedCol.table,
);
for (const col of fullColumns) {
if (col.name === matchedCol.name) {
results.push({
name: col.name,
table: matchedCol.table,
dataType: col.data_type,
isNullable: col.is_nullable,
columnDefault: col.column_default,
isPrimaryKey: col.is_primary_key,
comment: col.comment,
extra: col.extra,
});
}
}
} catch {
// Skip tables that fail
}
}
columnInfoColumns.value = results;
} catch (e: any) {
// Silently ignore errors
console.error("[DBX] Failed to fetch column info:", e);
return;
} finally {
columnInfoLoading.value = false;
showColumnInfo.value = true;
}
}
function closeColumnInfo() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
function onHandleClickTable(tableName: string) {
emit("clickTable", tableName);
}
function onHandleCloseColumnPanel() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
</script>
<template>
@@ -78,7 +160,7 @@ const hasNumericData = computed(() => {
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="40" :min-size="15">
<div class="h-full flex flex-col">
<div class="h-full flex flex-col relative">
<QueryEditor
class="flex-1"
:model-value="activeTab.sql"
@@ -92,6 +174,16 @@ const hasNumericData = computed(() => {
@cursor-change="emit('editorCursorChange', $event)"
@format-error="emit('formatError')"
@execute="emit('execute')"
@click-table="onHandleClickTable"
@click-column="onHandleClickColumn"
@close-column-panel="onHandleCloseColumnPanel"
/>
<ColumnInfoPanel
v-if="showColumnInfo"
:columns="columnInfoColumns"
:loading="columnInfoLoading"
:error="columnInfoError"
@close="closeColumnInfo"
/>
</div>
</Pane>
+1
View File
@@ -213,6 +213,7 @@ export default {
copySqlCondition: "Copy SQL Condition",
transpose: "Transpose Row",
rowsPerPageShort: " rows",
columnDetails: "Column Details",
},
welcome: {
title: "Database Workspace",
+1
View File
@@ -212,6 +212,7 @@ export default {
copySqlCondition: "复制 SQL 条件",
transpose: "转置查看",
rowsPerPageShort: " 行/页",
columnDetails: "列详情",
},
welcome: {
title: "数据库工作台",
+250 -17
View File
@@ -150,6 +150,7 @@ export interface SqlCompletionContext {
qualifier?: string;
suggestTables: boolean;
suggestColumns: boolean;
suggestKeywords: boolean;
referencedTables: SqlCompletionReferencedTable[];
}
@@ -174,6 +175,11 @@ export function buildSqlCompletionItemsFromContext(
): SqlCompletionItem[] {
const items: SqlCompletionItem[] = [];
// Always suggest keywords (regardless of qualifier)
if (context.suggestKeywords) {
items.push(...buildKeywordItems(context.prefix));
}
if (context.suggestColumns) {
items.push(...buildColumnItems(context, input.columnsByTable));
}
@@ -182,39 +188,230 @@ export function buildSqlCompletionItemsFromContext(
items.push(...buildTableItems(context.prefix, input.tables));
}
if (!context.qualifier) {
items.push(...buildKeywordItems(context.prefix));
}
return dedupeAndSort(items);
}
/**
* Find the start position of the SQL statement containing the cursor.
* Respects semicolons and string literals.
*/
function extractStatementStart(sql: string, cursor: number): number {
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = 0; i < sql.length; i++) {
const ch = sql[i];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote) {
if (i < cursor) {
start = i + 1;
while (start < sql.length && /\s/.test(sql[start])) start++;
}
}
}
return start;
}
/**
* Extract the full SQL statement that contains the cursor position.
* Respects semicolons and string literals.
*/
function extractStatementAt(sql: string, cursor: number): string {
const start = extractStatementStart(sql, cursor);
let end = sql.length;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let i = start; i < sql.length; i++) {
const ch = sql[i];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote && i >= cursor) {
end = i;
break;
}
}
return sql.slice(start, end).trim();
}
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
const beforeCursor = sql.slice(0, cursor);
// Extract the full statement at cursor position for referenced tables
const fullStatement = extractStatementAt(sql, cursor);
// Content before cursor within the current statement
const stmtStart = extractStatementStart(sql, cursor);
const beforeCursor = sql.slice(stmtStart, cursor);
const dottedMatch = /([A-Za-z_][\w$]*)\.([A-Za-z_][\w$]*)?$/.exec(beforeCursor);
const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor);
const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? "";
const qualifier = dottedMatch?.[1];
const bareStart = qualifier ? cursor - prefix.length : cursor - (plainMatch?.[1]?.length ?? 0);
const bareStart = qualifier
? beforeCursor.length - prefix.length
: beforeCursor.length - (plainMatch?.[1]?.length ?? 0);
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
const referencedTables = extractReferencedTables(sql);
const referencedTables = extractReferencedTables(fullStatement);
const afterTableTrigger =
TABLE_TRIGGER_KEYWORDS.has(lastWord) ||
(JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) ||
isInTableListContext(beforeToken);
// Check if we're in a context where columns are expected
const inColumnContext = isInColumnContext(beforeCursor);
return {
prefix,
qualifier,
suggestTables: afterTableTrigger || (!qualifier && prefix.length > 0),
suggestColumns: !!qualifier || referencedTables.length > 0,
// Suggest tables ONLY after FROM/JOIN/UPDATE/INTO/etc keywords
suggestTables: afterTableTrigger,
// Suggest columns when:
// 1. There's a table qualifier (table.column)
// 2. We're in a column context (WHERE, ON, SELECT, etc.) AND there are referenced tables
suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0),
// Always suggest keywords
suggestKeywords: true,
referencedTables,
};
}
/**
* Check if the content before cursor is in a column-expected context.
*/
function isInColumnContext(beforeCursor: string): boolean {
if (!beforeCursor) return false;
// Strip string literals
const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, "''");
// Get all words/tokens
const lastWords = cleaned.trimEnd().split(/\s+/);
// Check the last 3 words for column-context keywords
for (let i = lastWords.length - 1; i >= Math.max(0, lastWords.length - 3); i--) {
const word = lastWords[i]?.toLowerCase().replace(/[^a-z0-9.]/g, "") ?? "";
// Operators that indicate column context
if (/^[=<>!\+\-\*\/(,]$/.test(word)) return true;
// Keywords that directly precede column expressions
if (["where", "on", "having", "set", "and", "or", "not", "is", "like", "in", "between", "select"].includes(word)) {
return true;
}
// "ORDER BY" / "GROUP BY" — when we see "by", check the word before it
if (word === "by" && i > 0) {
const prevWord = lastWords[i - 1]?.toLowerCase() ?? "";
if (["order", "group"].includes(prevWord)) return true;
}
}
return false;
}
function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
// Keywords that should NOT be treated as table aliases
const ALIAS_BLACKLIST = new Set([
"where",
"group",
"order",
"having",
"limit",
"offset",
"union",
"intersect",
"except",
"and",
"or",
"not",
"is",
"like",
"in",
"between",
"exists",
"select",
"from",
"join",
"left",
"right",
"inner",
"outer",
"cross",
"full",
"natural",
"on",
"as",
"set",
"insert",
"update",
"delete",
"create",
"drop",
"alter",
"into",
"values",
"returning",
"for",
"window",
"partition",
"over",
"with",
"recursive",
"lateral",
"when",
"then",
"else",
"end",
"case",
"cast",
"coalesce",
"null",
"true",
"false",
"distinct",
"all",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"check",
"unique",
"index",
"table",
"view",
"database",
"schema",
"describe",
"explain",
"analyze",
"pivot",
"unpivot",
"asof",
"positional",
"anti",
"semi",
"sample",
"filter",
"qualify",
"offset",
"fetch",
"next",
"rows",
"only",
"preceding",
"following",
"current",
"unbounded",
"asc",
"desc",
"nulls",
"first",
"last",
"ignore",
"respect",
]);
const pattern =
/\b(?:from|join|update|into)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
const referenced: SqlCompletionReferencedTable[] = [];
@@ -223,7 +420,9 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
const alias = match[2];
const [first, second] = splitQualifiedName(rawName);
if (!first) continue;
const table = second ? { schema: first, name: second, alias } : { name: first, alias };
// Filter out SQL keywords that accidentally matched as aliases
const cleanAlias = alias && !ALIAS_BLACKLIST.has(alias.toLowerCase()) ? alias : undefined;
const table = second ? { schema: first, name: second, alias: cleanAlias } : { name: first, alias: cleanAlias };
referenced.push(table);
}
return referenced;
@@ -270,16 +469,50 @@ function buildColumnItems(
context: SqlCompletionContext,
columnsByTable: Map<string, SqlCompletionColumn[]>,
): SqlCompletionItem[] {
const relatedTables = context.qualifier
? context.referencedTables.filter((table) => table.alias === context.qualifier || table.name === context.qualifier)
: context.referencedTables;
// Collect all columns from the map (all tables have been fetched)
const allColumns: Array<SqlCompletionColumn & { key: string }> = [];
for (const [key, cols] of columnsByTable.entries()) {
for (const col of cols) {
allColumns.push({ ...col, key });
}
}
const columns = relatedTables.flatMap((table) => {
const key = table.schema ? `${table.schema}.${table.name}` : table.name;
return columnsByTable.get(key) ?? [];
// If there's a qualifier (e.g., c.card_name), filter to tables matching the qualifier
let relevantCols = allColumns;
if (context.qualifier) {
const q = context.qualifier;
const qLower = q.toLowerCase();
// Find tables whose name OR alias matches the qualifier
const relatedTables = context.referencedTables.filter(
(table) =>
table.alias === q ||
table.alias?.toLowerCase() === qLower ||
table.name === q ||
table.name.toLowerCase() === qLower,
);
// Build a set of actual table names to filter by
const tableNameSet = new Set(relatedTables.map((t) => t.name.toLowerCase()));
// Also build all possible key formats for columnsByTable matching
const tableKeys = new Set<string>();
for (const table of relatedTables) {
tableKeys.add(table.name);
if (table.schema) {
tableKeys.add(`${table.schema}.${table.name}`);
}
}
// Filter columns by matching the column's table name or the map key
relevantCols = allColumns.filter((c) => tableNameSet.has(c.table.toLowerCase()) || tableKeys.has(c.key));
}
// Deduplicate columns by name
const seen = new Set<string>();
const uniqueColumns = relevantCols.filter((c) => {
if (seen.has(c.name)) return false;
seen.add(c.name);
return true;
});
return columns
return uniqueColumns
.filter((column) => matchesPrefix(column.name, context.prefix))
.map((column) => ({
label: column.name,
+174
View File
@@ -0,0 +1,174 @@
/**
* Utilities for SQL identifier navigation (Ctrl/Cmd + click on table/column names).
*/
const SQL_KEYWORDS_SET = new Set([
"select",
"from",
"where",
"join",
"left",
"right",
"inner",
"outer",
"on",
"group",
"by",
"order",
"asc",
"desc",
"having",
"limit",
"offset",
"insert",
"into",
"values",
"update",
"set",
"delete",
"create",
"table",
"view",
"as",
"and",
"or",
"not",
"in",
"is",
"null",
"like",
"distinct",
"union",
"all",
"exists",
"between",
"case",
"when",
"then",
"else",
"end",
"count",
"sum",
"avg",
"min",
"max",
"coalesce",
"cast",
"alter",
"drop",
"add",
"column",
"index",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"check",
"unique",
"begin",
"commit",
"rollback",
"truncate",
"explain",
"analyze",
"with",
"recursive",
"over",
"partition",
"row_number",
"rank",
"dense_rank",
"lag",
"lead",
"first_value",
"last_value",
"ntile",
"cross",
"full",
"natural",
"using",
"lateral",
"unnest",
"filter",
"exclude",
"replace",
"qualify",
"pivot",
"unpivot",
"asof",
"positional",
"anti",
"semi",
"sample",
"struct",
"map",
"list",
"array",
"lambda",
"copy",
"export",
"import",
"describe",
"show",
"summarize",
"pragma",
"tablesample",
"read_csv",
"read_parquet",
"read_json",
"list_transform",
]);
/** Extract identifier at position `pos` in the document. */
export function extractIdentifierAt(doc: string, pos: number): string | null {
if (pos < 0 || pos > doc.length) return null;
const char = doc[pos];
const idChar = (c: string) => /^[A-Za-z0-9_]$/.test(c);
// Backtick-quoted: `identifier`
if (char === "`") {
let start = pos;
while (start > 0 && doc[start - 1] === "`") start--;
const end = doc.indexOf("`", start + 1);
if (end < 0) return null;
return doc.slice(start + 1, end);
}
// Double-quoted: "identifier"
if (char === '"') {
let start = pos;
while (start > 0 && doc[start - 1] === '"') start--;
const end = doc.indexOf('"', start + 1);
if (end < 0) return null;
return doc.slice(start + 1, end);
}
// Unquoted identifier (may be qualified like schema.table)
if (!idChar(char) && char !== ".") return null;
let start = pos;
while (start > 0 && (idChar(doc[start - 1]) || doc[start - 1] === ".")) start--;
let end = pos;
while (end < doc.length && (idChar(doc[end]) || doc[end] === ".")) end++;
const result = doc.slice(start, end);
if (!/[A-Za-z0-9_]/.test(result)) return null;
return result;
}
/** Check whether the identifier is a SQL keyword (not a table/column name). */
export function isSqlKeyword(identifier: string): boolean {
return SQL_KEYWORDS_SET.has(identifier.toLowerCase());
}
/** Match identifier against known table names (case-insensitive). */
export function matchTable(
identifier: string,
tables: Array<{ name: string; schema?: string }>,
): { name: string; schema?: string } | null {
const lower = identifier.toLowerCase();
return tables.find((t) => t.name.toLowerCase() === lower) ?? null;
}