fix(query): 修复查询结果表头提示与全量排序 (#164)

* fix(grid): 修复查询结果表头提示与排序

修复 MySQL 查询结果列元数据取错库名的问题,并补齐查询结果全量排序链路,避免表头提示与排序行为失效。

* fix(query): 修复结果排序重复列名报错
This commit is contained in:
Bacon2994 2026-05-08 18:31:09 +08:00 committed by GitHub
parent c036dd19e4
commit 3b4b6c1ff7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 246 additions and 51 deletions

View File

@ -297,7 +297,7 @@ pub async fn get_columns_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) => db::mysql::get_columns(p, schema, table).await,
PoolKind::Mysql(p, _) => db::mysql::get_columns(p, database, table).await,
PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await,
_ => Ok(vec![]),

View File

@ -98,7 +98,7 @@ const props = defineProps<{
const emit = defineEmits<{
reload: [sql?: string, whereInput?: string];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
}>();
const hasData = computed(() => props.result.columns.length > 0);
@ -203,6 +203,7 @@ const showCellDetail = ref(false);
const transposeRowIndex = ref<number | null>(null);
const showTranspose = ref(false);
const sortCol = ref<string | null>(null);
const sortColIndex = ref<number | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const searchSuggestions = ref<string[]>([]);
@ -599,21 +600,23 @@ const activeCellDetail = computed(() => {
};
});
function toggleSort(colName: string) {
function toggleSort(colName: string, colIdx: number) {
if (isResizing) return;
if (sortCol.value === colName) {
if (sortCol.value === colName && sortColIndex.value === colIdx) {
if (sortDir.value === "asc") {
sortDir.value = "desc";
emit("sort", colName, "desc", activeWhereInput.value);
emit("sort", colName, colIdx, "desc", activeWhereInput.value);
} else {
sortCol.value = null;
sortColIndex.value = null;
sortDir.value = "asc";
emit("sort", colName, null, activeWhereInput.value);
emit("sort", colName, colIdx, null, activeWhereInput.value);
}
} else {
sortCol.value = colName;
sortColIndex.value = colIdx;
sortDir.value = "asc";
emit("sort", colName, "asc", activeWhereInput.value);
emit("sort", colName, colIdx, "asc", activeWhereInput.value);
}
}
@ -1415,17 +1418,23 @@ defineExpose({
>
#
</div>
<Tooltip v-for="(col, colIdx) in result.columns" :key="col">
<Tooltip v-for="(col, colIdx) in result.columns" :key="`${col}-${colIdx}`">
<TooltipTrigger as-child>
<div
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative overflow-hidden"
:style="{ width: `var(--col-w-${colIdx})` }"
@click="toggleSort(col)"
@click="toggleSort(col, colIdx)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="min-w-0 truncate">{{ col }}</span>
<ArrowUp v-if="sortCol === col && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="sortCol === col && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUp
v-if="sortCol === col && sortColIndex === colIdx && sortDir === 'asc'"
class="h-3 w-3 shrink-0"
/>
<ArrowDown
v-else-if="sortCol === col && sortColIndex === colIdx && sortDir === 'desc'"
class="h-3 w-3 shrink-0"
/>
</span>
<div
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
@ -1434,12 +1443,14 @@ defineExpose({
</div>
</TooltipTrigger>
<TooltipContent
v-if="columnTypeMap.get(col)"
v-if="columnTypeMap.get(col) || columnCommentMap.get(col)"
side="bottom"
class="text-xs grid grid-cols-[auto_1fr] gap-x-2"
>
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span :class="typeColorClass(columnTypeMap.get(col)!)">{{ columnTypeMap.get(col) }}</span>
<template v-if="columnTypeMap.get(col)">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span :class="typeColorClass(columnTypeMap.get(col)!)">{{ columnTypeMap.get(col) }}</span>
</template>
<template v-if="columnCommentMap.get(col)">
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
<span>{{ columnCommentMap.get(col) }}</span>

View File

@ -41,7 +41,7 @@ const emit = defineEmits<{
formatError: [];
reload: [sql?: string, whereInput?: string];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
executeSql: [sql: string];
clickTable: [tableName: string];
}>();
@ -281,8 +281,8 @@ function onHandleCloseColumnPanel() {
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, direction, whereInput)
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, columnIndex, direction, whereInput)
"
/>
<div
@ -374,8 +374,8 @@ function onHandleCloseColumnPanel() {
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, direction, whereInput)
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, columnIndex, direction, whereInput)
"
/>
<div

View File

@ -1,10 +1,15 @@
import { type ComputedRef } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { buildSortedQuerySql } from "@/lib/queryResultSort";
import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>) {
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@ -44,13 +49,24 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
if (!tab) return;
if (tab.mode === "data" && tab.tableMeta) {
queryStore.updateSql(tab.id, buildTableSql(tab, { whereInput }));
return queryStore.executeCurrentTab();
await queryStore.executeCurrentTab();
return;
}
if (tab.resultSortedSql) {
await queryStore.executeTabSql(tab.id, tab.resultSortedSql, {
resultBaseSql: tab.resultBaseSql ?? tab.sql,
resultSortedSql: tab.resultSortedSql,
});
return;
}
// Results mode: re-run only the SQL that produced the current result set
if (sql?.trim()) {
return queryStore.executeTabSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
resultBaseSql: sql,
resultSortedSql: undefined,
});
return;
}
return queryStore.executeCurrentTab();
await queryStore.executeCurrentTab();
}
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
@ -61,13 +77,48 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
await queryStore.executeCurrentTab();
}
async function onSort(column: string, direction: "asc" | "desc" | null, whereInput?: string) {
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
const sql = buildTableSql(tab, { orderBy, whereInput });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
if (!tab) return;
if (tab.mode === "data") {
if (!tab.tableMeta) return;
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
const sql = buildTableSql(tab, { orderBy, whereInput });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
return;
}
const baseSql = tab.resultBaseSql ?? tab.sql;
if (!baseSql.trim()) return;
if (!direction) {
await queryStore.executeTabSql(tab.id, baseSql, {
resultBaseSql: baseSql,
resultSortedSql: undefined,
});
return;
}
const config = connectionStore.getConfig(tab.connectionId);
const built = buildSortedQuerySql(
baseSql,
config?.db_type,
tab.result?.columns ?? [],
columnIndex,
column,
direction,
);
if (!built.ok) {
toast(t("grid.sortUnsupported"), 5000);
return;
}
await queryStore.executeTabSql(tab.id, built.sql, {
resultBaseSql: baseSql,
resultSortedSql: built.sql,
});
}
return { onExecuteSql, onReloadData, onPaginate, onSort };

View File

@ -223,6 +223,7 @@ export default {
commit: "Commit",
rollback: "Rollback",
transactionActive: "Editing",
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
},
welcome: {
title: "Database Workspace",

View File

@ -222,6 +222,7 @@ export default {
commit: "提交",
rollback: "回滚",
transactionActive: "编辑中",
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
},
welcome: {
title: "数据库工作台",

View File

@ -0,0 +1,71 @@
import type { DatabaseType } from "../types/database.ts";
import { quoteTableIdentifier } from "./tableSelectSql.ts";
import { findStatementAtCursor } from "./sqlStatementSplit.ts";
export type QuerySortDirection = "asc" | "desc";
export interface SortedQuerySqlResult {
ok: true;
sql: string;
}
export interface SortedQuerySqlError {
ok: false;
reason: "empty" | "multi" | "not_select" | "with";
}
export function buildSortedQuerySql(
originalSql: string,
databaseType: DatabaseType | undefined,
resultColumns: string[],
columnIndex: number,
column: string,
direction: QuerySortDirection,
): SortedQuerySqlResult | SortedQuerySqlError {
const baseSql = originalSql.trim();
if (!baseSql) return { ok: false, reason: "empty" };
const statement = findStatementAtCursor(baseSql, 0)
.trim()
.replace(/;+\s*$/, "")
.trim();
if (!statement) return { ok: false, reason: "empty" };
if (statement.length !== baseSql.replace(/;+\s*$/, "").trim().length) {
return { ok: false, reason: "multi" };
}
if (/^\s*WITH\b/i.test(statement)) {
return { ok: false, reason: "with" };
}
if (!/^\s*SELECT\b/i.test(statement)) {
return { ok: false, reason: "not_select" };
}
const aliases = buildDerivedColumnAliases(resultColumns);
const sortAlias = aliases[columnIndex] ?? aliases[resultColumns.indexOf(column)] ?? fallbackAlias(columnIndex);
const quotedColumn = quoteTableIdentifier(databaseType, sortAlias);
const aliasList = aliases.map((alias) => quoteTableIdentifier(databaseType, alias)).join(", ");
return {
ok: true,
sql: `SELECT * FROM (${statement}) t(${aliasList}) ORDER BY ${quotedColumn} ${direction.toUpperCase()};`,
};
}
function buildDerivedColumnAliases(resultColumns: string[]): string[] {
const seen = new Map<string, number>();
return resultColumns.map((column, index) => {
const base = normalizeAliasBase(column, index);
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
return count === 1 ? base : `${base}_${count}`;
});
}
function normalizeAliasBase(column: string, index: number): string {
const compact = column.trim().replace(/\s+/g, "_");
const safe = compact.replace(/[^\p{L}\p{N}_$]/gu, "_").replace(/^_+|_+$/g, "");
return safe || fallbackAlias(index);
}
function fallbackAlias(index: number): string {
return `column_${index + 1}`;
}

View File

@ -1,5 +1,5 @@
import type { DatabaseType } from "../types/database.ts";
import { isSchemaAware, usesFetchFirst } from "@/lib/databaseCapabilities";
import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;

View File

@ -136,7 +136,11 @@ export const useQueryStore = defineStore("query", () => {
function updateSql(id: string, sql: string) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) tab.sql = sql;
if (tab) {
tab.sql = sql;
tab.resultSortedSql = undefined;
tab.resultBaseSql = undefined;
}
}
function togglePinnedTab(id: string) {
@ -153,6 +157,8 @@ export const useQueryStore = defineStore("query", () => {
tab.schema = undefined;
tab.result = undefined;
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
}
@ -171,6 +177,8 @@ export const useQueryStore = defineStore("query", () => {
tab.schema = undefined;
tab.result = undefined;
tab.lastExecutedSql = undefined;
tab.resultBaseSql = undefined;
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
}
@ -226,14 +234,13 @@ export const useQueryStore = defineStore("query", () => {
async function executeCurrentSql(sql: string) {
if (!activeTabId.value) return;
await executeTabSql(activeTabId.value, sql);
await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined });
}
/**
* Analyze if the query result is editable (single-table SELECT with primary keys).
* If editable, fetches table metadata and sets queryAnalysis + tableMeta on the tab.
* Analyze query metadata for result tooltips and editability.
*/
async function analyzeQueryEditability(tab: QueryTab, sql: string) {
async function analyzeQueryMetadata(tab: QueryTab, sql: string) {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
tab.queryAnalysis = undefined;
@ -268,33 +275,31 @@ export const useQueryStore = defineStore("query", () => {
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
if (primaryKeys.length === 0) {
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
return;
}
tab.tableMeta = {
schema: schema || undefined,
tableName: analysis.tableName,
columns,
primaryKeys,
};
if (primaryKeys.length === 0 || !allPrimaryKeysPresent(primaryKeys, tab.result.columns)) {
tab.queryAnalysis = undefined;
return;
}
tab.queryAnalysis = analysis;
} catch (err) {
console.error("[DBX] ERROR fetching columns for editable query:", err);
console.error("[DBX] ERROR fetching columns for query metadata:", err);
tab.queryAnalysis = undefined;
tab.tableMeta = undefined;
}
}
async function executeTabSql(id: string, sql: string) {
async function executeTabSql(
id: string,
sql: string,
options?: { resultBaseSql?: string; resultSortedSql?: string | undefined },
) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !sql.trim()) return;
@ -316,8 +321,9 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined;
current.result = results[0];
}
// Analyze editability after successful execution
await analyzeQueryEditability(current, sql);
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
await analyzeQueryMetadata(current, current.resultBaseSql);
}
} catch (e: any) {
const current = tabs.value.find((t) => t.id === id);
@ -326,6 +332,9 @@ export const useQueryStore = defineStore("query", () => {
current.results = undefined;
current.activeResultIndex = undefined;
current.queryAnalysis = undefined;
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
}
} finally {
const current = tabs.value.find((t) => t.id === id);

View File

@ -152,6 +152,8 @@ export interface QueryTab {
schema?: string;
sql: string;
lastExecutedSql?: string;
resultBaseSql?: string;
resultSortedSql?: string;
pinned?: boolean;
result?: QueryResult;
results?: QueryResult[];

View File

@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSortedQuerySql } from "../src/lib/queryResultSort.ts";
test("wraps a single select query with outer order by", () => {
const result = buildSortedQuerySql("SELECT id, name FROM users;", "postgres", ["id", "name"], 1, "name", "asc");
assert.deepEqual(result, {
ok: true,
sql: 'SELECT * FROM (SELECT id, name FROM users) t("id", "name") ORDER BY "name" ASC;',
});
});
test("preserves complex select body when wrapping sort sql", () => {
const result = buildSortedQuerySql("SELECT id FROM users WHERE status = 'A'", "mysql", ["id"], 0, "id", "desc");
assert.deepEqual(result, {
ok: true,
sql: "SELECT * FROM (SELECT id FROM users WHERE status = 'A') t(`id`) ORDER BY `id` DESC;",
});
});
test("assigns unique aliases for duplicate result column names", () => {
const result = buildSortedQuerySql(
"SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id",
"mysql",
["id", "id"],
1,
"id",
"asc",
);
assert.deepEqual(result, {
ok: true,
sql: "SELECT * FROM (SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id) t(`id`, `id_2`) ORDER BY `id_2` ASC;",
});
});
test("rejects multiple statements for result sorting", () => {
const result = buildSortedQuerySql("SELECT 1; SELECT 2;", "postgres", ["id"], 0, "id", "asc");
assert.deepEqual(result, { ok: false, reason: "multi" });
});
test("rejects cte queries for result sorting", () => {
const result = buildSortedQuerySql("WITH cte AS (SELECT 1) SELECT * FROM cte", "postgres", ["id"], 0, "id", "asc");
assert.deepEqual(result, { ok: false, reason: "with" });
});
test("rejects non select statements for result sorting", () => {
const result = buildSortedQuerySql("UPDATE users SET name = 'A'", "postgres", ["name"], 0, "name", "asc");
assert.deepEqual(result, { ok: false, reason: "not_select" });
});