mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 00:09:52 +00:00
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:
+22
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -213,6 +213,7 @@ export default {
|
||||
copySqlCondition: "Copy SQL Condition",
|
||||
transpose: "Transpose Row",
|
||||
rowsPerPageShort: " rows",
|
||||
columnDetails: "Column Details",
|
||||
},
|
||||
welcome: {
|
||||
title: "Database Workspace",
|
||||
|
||||
@@ -212,6 +212,7 @@ export default {
|
||||
copySqlCondition: "复制 SQL 条件",
|
||||
transpose: "转置查看",
|
||||
rowsPerPageShort: " 行/页",
|
||||
columnDetails: "列详情",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
|
||||
+250
-17
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user