fix(grid): show comments for joined query results

This commit is contained in:
jearhe authored and GitHub committed 2026-08-16 15:34:03 +08:00
1 parent d5f18034f9
commit 64e5e88c27
11 files changed
+867 -9

No files matched your search

+29 -2
View File
@@ -71,7 +71,7 @@ import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWor
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab } from "@/types/database";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
@@ -346,6 +346,22 @@ interface DataGridProps {
context?: "results" | "table-data";
autoTransposeSingleRow?: boolean;
sourceColumns?: Array<string | undefined>;
/**
* Column comments for a multi-source query result (e.g. JOIN), indexed by
* result-column ordinal (projection order). Populated even when the result is
* not editable, so joined results still show comments. `undefined` for a
* column that cannot be resolved back to exactly one base column (ambiguous
* or computed) — the grid shows no comment instead of a wrong one.
*/
resultColumnComments?: Array<string | undefined>;
/**
* Display-only result-column -> source mapping for multi-source results,
* indexed by result-column ordinal; each entry carries the source identity
* (sourceKey + canonical source column name). Used to resolve column
* comments per source instead of first-source-wins; never used for row
* identity or editing.
*/
queryDisplaySourceColumns?: Array<QueryResultSourceColumnRef | undefined>;
initialWhereInput?: string;
initialOrderByInput?: string;
sortColumn?: string;
@@ -586,6 +602,11 @@ const readonlyTextCell = ref<{
} | null>(null);
function resolvedColumnComment(column: string, actualColIdx: number): string | undefined {
// Multi-source results resolve comments per result ordinal; ambiguous or
// unresolved columns yield undefined instead of falling back to a
// first-source-wins name map.
const ordinalComments = props.resultColumnComments;
if (ordinalComments) return ordinalComments[actualColIdx];
return dataGridColumnCommentFor(columnCommentMap.value, column, props.sourceColumns?.[actualColIdx]);
}
@@ -2116,7 +2137,13 @@ const allColumnTypes = computed(() =>
const visibleColumnTypes = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypes.value[index]));
const allColumnTypeVisualKinds = computed(() => allColumnTypes.value.map((type) => resolveDataGridTypeVisualKind(type, resolvedDatabaseType.value)));
const visibleColumnTypeVisualKinds = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypeVisualKinds.value[index] ?? "unknown"));
const visibleColumnComments = computed(() => visibleColumnIndexes.value.map((index) => dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index])));
const visibleColumnComments = computed(() =>
visibleColumnIndexes.value.map((index) => {
const ordinalComments = props.resultColumnComments;
if (ordinalComments) return ordinalComments[index];
return dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index]);
}),
);
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
const numericColumnRightAlign = computed(() => (settingsStore.editorSettings.numericColumnRightAlign ?? true) && !showTranspose.value);
@@ -4,8 +4,10 @@ import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid column comments", () => {
it("uses source column metadata for both inline and tooltip header comments", () => {
expect(dataGridSource).toMatch(/function resolvedColumnComment\(column: string, actualColIdx: number\)[\s\S]*?dataGridColumnCommentFor\([\s\S]*?props\.sourceColumns\?\.\[actualColIdx\][\s\S]*?\);\s*\}/);
it("uses per-ordinal multi-source comments, falling back to source metadata for single-source grids", () => {
expect(dataGridSource).toMatch(
/function resolvedColumnComment\(column: string, actualColIdx: number\)[\s\S]*?const ordinalComments = props\.resultColumnComments;[\s\S]*?if \(ordinalComments\) return ordinalComments\[actualColIdx\];[\s\S]*?dataGridColumnCommentFor\([\s\S]*?props\.sourceColumns\?\.\[actualColIdx\][\s\S]*?\);\s*\}/,
);
expect(dataGridSource).toContain(':column-comment="headerColumnComment(col.name, col.actualColIdx)"');
expect(dataGridSource).toContain(':tooltip-column-comment="resolvedColumnComment(col.name, col.actualColIdx)"');
expect(dataGridSource).toContain("(column, index) => headerColumnComment(column, index)");
@@ -1568,6 +1568,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:loading="activeTab.isExecuting"
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
:source-columns="activeTab.querySourceColumns"
:result-column-comments="activeTab.resultColumnComments"
:query-display-source-columns="activeTab.queryDisplaySourceColumns"
:custom-save-handler="mongoQueryResultSaveHandler"
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
:query-editability-reason="activeTab.queryEditabilityReason"
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis";
/**
* Result columns resolve by projection ordinal, each carrying its source
* identity (sourceKey + canonical source column), so joined results keep
* per-source comments instead of first-source-wins on name clashes.
*/
describe("multi-source result column mapping", () => {
it("parses a JOIN as multi-source with per-source columns", () => {
const result = analyzeEditableQueryEditability("SELECT a.id, a.user_id, b.name FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const sources = result.analysis.sources!;
expect(sources.map((source) => source.tableName)).toEqual(["orders", "users"]);
expect(result.analysis.columns.map((column) => column.resultName)).toEqual(["id", "user_id", "name"]);
expect(result.analysis.columns.map((column) => column.sourceKey)).toEqual(["a:0", "a:0", "b:1"]);
});
it("maps each JOIN result column back to (source, column) by ordinal", () => {
const result = analyzeEditableQueryEditability("SELECT a.id, a.user_id, b.name FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
3,
);
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "id" },
{ sourceKey: "a:0", sourceColumn: "user_id" },
{ sourceKey: "b:1", sourceColumn: "name" },
]);
});
it("keeps duplicate result column names resolved per source (both tables have id)", () => {
const result = analyzeEditableQueryEditability("SELECT a.id, b.id FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
2,
);
// Result column #0 is orders.id, #1 is users.id — no first-source-wins.
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "id" },
{ sourceKey: "b:1", sourceColumn: "id" },
]);
});
it("resolves a uniquely qualified unqualified alias (name AS username) back to its physical column", () => {
const result = analyzeEditableQueryEditability("SELECT name AS username FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
1,
);
expect(resolved).toEqual([{ sourceKey: "b:1", sourceColumn: "name" }]);
});
it("returns undefined for an ambiguous unqualified column shared by several sources", () => {
const result = analyzeEditableQueryEditability("SELECT id FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
1,
);
expect(resolved).toEqual([undefined]);
});
it("resolves quoted mixed-case identifiers exactly (case preserved)", () => {
const result = analyzeEditableQueryEditability('SELECT a."ID", b."Name" FROM orders a JOIN users b ON a.user_id = b.id');
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"postgres",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "ID" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "Name" }] },
],
2,
);
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "ID" },
{ sourceKey: "b:1", sourceColumn: "Name" },
]);
});
it("does not collapse an unquoted lower-case name onto a distinct quoted mixed-case column", () => {
const result = analyzeEditableQueryEditability('SELECT a.id, b."ID" FROM orders a JOIN users b ON a.user_id = b.id');
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"postgres",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "ID" }, { name: "name" }] },
],
2,
);
// a.id folds to postgres `id`; b."ID" matches the quoted column exactly.
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "id" },
{ sourceKey: "b:1", sourceColumn: "ID" },
]);
});
it("expands a qualified star projection in projection order", () => {
const result = analyzeEditableQueryEditability("SELECT a.*, b.name FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
4,
);
expect(resolved).toEqual([
{ sourceKey: "a:0", sourceColumn: "id" },
{ sourceKey: "a:0", sourceColumn: "user_id" },
{ sourceKey: "a:0", sourceColumn: "amount" },
{ sourceKey: "b:1", sourceColumn: "name" },
]);
});
it("returns undefined for computed columns and extra result columns", () => {
const result = analyzeEditableQueryEditability("SELECT a.id, a.amount + b.id AS total, b.name FROM orders a JOIN users b ON a.user_id = b.id");
expect(result.editable).toBe(true);
if (!result.editable) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
result.analysis,
[
{ source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] },
{ source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] },
],
3,
);
expect(resolved).toEqual([{ sourceKey: "a:0", sourceColumn: "id" }, undefined, { sourceKey: "b:1", sourceColumn: "name" }]);
});
});
@@ -193,6 +193,60 @@ describe("tab result cache statement execution metadata", () => {
expect(restored?.querySourceColumns).toEqual(["id", "name"]);
});
it("restores multi-source column comments and their source identity from the snapshot", () => {
const snapshot = {
result: {
columns: ["id", "user_id", "id_1", "name"],
rows: [[1, 100, 7, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
resultRuns: [
{
id: "run-join",
title: "Run 1",
sequence: 1,
sql: "SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id",
createdAt: 1,
result: {
columns: ["id", "user_id", "id_1", "name"],
rows: [[1, 100, 7, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
resultColumnComments: ["订单ID", "下单用户", "用户ID", "用户名"],
queryDisplaySourceColumns: [
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "a", sourceColumn: "user_id" },
{ sourceKey: "b", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "name" },
],
},
],
activeResultRunId: "run-join",
resultColumnComments: ["订单ID", "下单用户", "用户ID", "用户名"],
queryDisplaySourceColumns: [
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "a", sourceColumn: "user_id" },
{ sourceKey: "b", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "name" },
],
cachedAt: 1,
};
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(snapshot));
expect(restored?.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]);
expect(restored?.queryDisplaySourceColumns).toEqual([
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "a", sourceColumn: "user_id" },
{ sourceKey: "b", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "name" },
]);
expect(restored?.resultRuns?.[0]?.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]);
expect(restored?.resultRuns?.[0]?.queryDisplaySourceColumns?.[2]).toEqual({ sourceKey: "b", sourceColumn: "id" });
});
it("treats corrupt and unsupported snapshots as missing", () => {
expect(decodeTabResultSnapshot(new Uint8Array([0xff, 0x00]))).toBeUndefined();
const encoded = encodeTabResultSnapshot(queryResultLifecycleSnapshot());
+108
View File
@@ -86,6 +86,114 @@ export function resolveMetadataColumnName(databaseType: string, sourceName: stri
return caseOnlyMatches.length === 1 ? caseOnlyMatches[0] : undefined;
}
export interface ResolvedSourceColumnRef {
sourceKey: string;
sourceColumn: string;
}
/**
* Expand `*` / `alias.*` projections against each source table's columns so the
* returned stream aligns 1:1 with the executed result columns (projection
* order). A star whose source table is not among `tableSources` collapses to
* `undefined` (unresolvable). Whole-table `SELECT *` is expanded against the
* single source table when present.
*/
function expandProjectionColumnsForSources(analysis: EditableQueryInfo, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>): Array<EditableQueryColumn | undefined> {
if (analysis.selectStar || analysis.columns.length === 0) {
return tableSources.flatMap(({ source, columns }) =>
columns.map((column) => ({
sourceName: column.name,
sourceNameQuoted: false,
sourceKey: source.key,
resultName: column.name,
expression: column.name,
})),
);
}
const expanded: Array<EditableQueryColumn | undefined> = [];
for (const column of analysis.columns) {
if (!column.star) {
expanded.push(column);
continue;
}
const tableSource = tableSources.find((entry) => entry.source.key === column.sourceKey);
if (!tableSource) {
expanded.push(undefined);
continue;
}
for (const tableColumn of tableSource.columns) {
expanded.push({
...column,
star: false,
sourceName: tableColumn.name,
sourceNameQuoted: false,
resultName: tableColumn.name,
expression: column.sourceQualifier ? `${column.sourceQualifier}.${tableColumn.name}` : tableColumn.name,
});
}
}
return expanded;
}
function resolveProjectionColumnToSource(databaseType: string, column: EditableQueryColumn | undefined, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>): ResolvedSourceColumnRef | undefined {
if (!column || column.star || !column.sourceName) return undefined;
// A qualified reference whose qualifier could not be bound to a unique source
// stays unresolved rather than guessing from the bare column name.
if (column.sourceQualifier && !column.sourceKey) return undefined;
if (column.sourceKey) {
const tableIndex = tableSources.findIndex((entry) => entry.source.key === column.sourceKey);
if (tableIndex < 0) return undefined;
const canonicalName = resolveMetadataColumnName(
databaseType,
column.sourceName,
column.sourceNameQuoted,
tableSources[tableIndex]!.columns.map((entry) => entry.name),
);
return canonicalName ? { sourceKey: column.sourceKey, sourceColumn: canonicalName } : undefined;
}
// Unqualified reference: bind only when exactly one source resolves it, so an
// ambiguous name shared by several tables yields undefined instead of
// first-source-wins.
const matches: ResolvedSourceColumnRef[] = [];
for (const tableSource of tableSources) {
const canonicalName = resolveMetadataColumnName(
databaseType,
column.sourceName,
column.sourceNameQuoted,
tableSource.columns.map((entry) => entry.name),
);
if (canonicalName) matches.push({ sourceKey: tableSource.source.key, sourceColumn: canonicalName });
}
return matches.length === 1 ? matches[0] : undefined;
}
/**
* Resolve each result column — in projection (ordinal) order — back to exactly
* one base-table column across the query sources, using the same
* database-aware identifier canonicalization as the editability binder
* (`resolveMetadataColumnName`): quoted identifiers match metadata exactly
* (case preserved), unquoted identifiers fold per the database's rules
* (PostgreSQL-compatible lower, Oracle-compatible upper, others
* case-insensitive when unambiguous).
*
* Star projections are expanded against the source table columns so the
* returned array aligns 1:1 with the executed result columns. An entry is
* `undefined` when the result column cannot be resolved to a single source
* column: ambiguous unqualified references, computed expressions, unknown
* columns, or a star whose source table is unknown. Consumers must show no
* comment for such columns rather than guessing.
*/
export function resolveSourceColumnsByOrdinal(databaseType: string, analysis: EditableQueryInfo, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>, columnCount: number): Array<ResolvedSourceColumnRef | undefined> {
const expanded = expandProjectionColumnsForSources(analysis, tableSources);
const resolved: Array<ResolvedSourceColumnRef | undefined> = [];
for (let index = 0; index < columnCount; index++) {
resolved.push(resolveProjectionColumnToSource(databaseType, expanded[index], tableSources));
}
return resolved;
}
export type QueryEditabilityReason = "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable";
export type QueryEditability = { editable: true; analysis: EditableQueryInfo } | { editable: false; reason: QueryEditabilityReason };
+9 -1
View File
@@ -34,6 +34,8 @@ export interface TabResultSnapshot {
activeResultRunId?: string;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
resultColumnComments?: QueryTab["resultColumnComments"];
queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
tableMeta?: QueryTab["tableMeta"];
@@ -318,7 +320,11 @@ async function deleteIndexedDbCacheOwner(ownerId: string): Promise<void> {
function clonePlain<T>(value: T): T {
const raw = toRaw(value);
if (typeof structuredClone === "function") return structuredClone(raw);
return JSON.parse(JSON.stringify(raw)) as T;
try {
return JSON.parse(JSON.stringify(raw)) as T;
} catch {
return raw;
}
}
function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined {
@@ -727,6 +733,8 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
activeResultRunId: tab.activeResultRunId,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
resultColumnComments: tab.resultColumnComments ? clonePlain(tab.resultColumnComments) : undefined,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns ? [...tab.queryDisplaySourceColumns] : undefined,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget ? clonePlain(tab.mongoEditTarget) : undefined,
tableMeta: tab.tableMeta ? clonePlain(tab.tableMeta) : undefined,
@@ -0,0 +1,369 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const executeMulti = vi.fn();
const executeQuery = vi.fn();
const analyzeEditableQueryEditability = vi.fn();
const getColumns = vi.fn();
const listIndexes = vi.fn();
const listObjects = vi.fn();
const getConnectionConfig = vi.fn();
const lookupLocalCompletionTables = vi.fn();
const buildSortedQuerySql = vi.fn();
const buildDataGridCountSql = vi.fn();
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
const editorSettings = {
pageSize: 100,
autoCalculateTotalRows: false,
};
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability,
buildDataGridCountSql,
buildSortedQuerySql,
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
closeQuerySession: vi.fn().mockResolvedValue(undefined),
executeMulti,
executeQuery,
getColumns,
listIndexes,
listObjects,
prepareQueryPaginationExecutionPlan,
saveOpenTabsState: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: getConnectionConfig,
lookupLocalCompletionTables,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings,
}),
}));
function column(name: string, comment: string | null, isPrimaryKey = false) {
return { name, data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: isPrimaryKey, extra: null, comment };
}
const ordersColumns = [column("id", "订单ID", true), column("user_id", "下单用户"), column("amount", "订单金额")];
const usersColumns = [column("id", "用户ID", true), column("name", "用户名")];
/** SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id */
const joinAnalysis = {
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
tableAlias: "a",
selectStar: false,
columns: [
{ sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" },
{ sourceName: "user_id", sourceKey: "a", resultName: "user_id", expression: "a.user_id" },
{ sourceName: "id", sourceKey: "b", resultName: "id_1", expression: "b.id" },
{ sourceName: "name", sourceKey: "b", resultName: "name", expression: "b.name" },
],
sources: [
{ key: "a", tableName: "orders", alias: "a" },
{ key: "b", tableName: "users", alias: "b" },
],
multiSource: true,
allowInsertDelete: false,
},
};
describe("queryStore multi-source result column comments", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache");
clearTableMetadataCache();
setActivePinia(createPinia());
getConnectionConfig.mockReturnValue({ id: "mysql-1", name: "MySQL", db_type: "mysql", database: "app", query_timeout_secs: 30 });
getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "orders" ? ordersColumns : usersColumns));
listIndexes.mockResolvedValue([]);
listObjects.mockResolvedValue([]);
lookupLocalCompletionTables.mockReturnValue([]);
analyzeEditableQueryEditability.mockResolvedValue(joinAnalysis);
buildSortedQuerySql.mockResolvedValue({ ok: true, sql: `${"SELECT *"} ORDER BY 1` });
buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `orders`");
executeQuery.mockResolvedValue({
columns: ["row_count"],
rows: [[0]],
affected_rows: 0,
execution_time_ms: 1,
});
executeMulti.mockResolvedValue([
{
columns: ["id", "user_id", "id_1", "name"],
rows: [[1, 100, 7, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
});
afterEach(() => {
expect(listObjects).not.toHaveBeenCalled();
});
it("stores per-ordinal comments and source identity for every JOIN source column", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
// Multi-source results stay non-editable: no single tableMeta.
expect(tab.tableMeta).toBeUndefined();
expect(tab.queryEditabilityReason).toBe("complex-source");
expect(tab.querySourceColumns).toBeUndefined();
// Comments are indexed by result ordinal, so the second `id` (users.id)
// keeps its own comment instead of first-source-wins on the name.
expect(tab.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]);
// The display mapping carries source identity per ordinal.
expect(tab.queryDisplaySourceColumns).toEqual([
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "a", sourceColumn: "user_id" },
{ sourceKey: "b", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "name" },
]);
});
it("resolves a uniquely qualified unqualified alias back to its physical column", async () => {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
tableAlias: "a",
selectStar: false,
columns: [
{ sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" },
{ sourceName: "name", sourceKey: undefined, resultName: "username", expression: "name" },
],
sources: [
{ key: "a", tableName: "orders", alias: "a" },
{ key: "b", tableName: "users", alias: "b" },
],
multiSource: true,
allowInsertDelete: false,
},
});
executeMulti.mockResolvedValue([
{
columns: ["id", "username"],
rows: [[1, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
// `name` exists only in users across the joined sources, so the unqualified
// alias resolves back to users.name despite the binder never seeing a key.
await store.executeTabSql(tabId, "SELECT a.id, name AS username FROM orders a JOIN users b ON a.user_id = b.id");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
expect(tab.resultColumnComments).toEqual(["订单ID", "用户名"]);
expect(tab.queryDisplaySourceColumns).toEqual([
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "name" },
]);
});
it("returns no comment for an ambiguous unqualified column shared by several sources", async () => {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
tableAlias: "a",
selectStar: false,
columns: [{ sourceName: "id", sourceKey: undefined, resultName: "id", expression: "id" }],
sources: [
{ key: "a", tableName: "orders", alias: "a" },
{ key: "b", tableName: "users", alias: "b" },
],
multiSource: true,
allowInsertDelete: false,
},
});
executeMulti.mockResolvedValue([
{
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
// Both orders and users expose `id`; the unqualified reference is
// ambiguous, so the result column must not claim either table's comment.
await store.executeTabSql(tabId, "SELECT id FROM orders a JOIN users b ON a.user_id = b.id");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
expect(tab.resultColumnComments).toEqual([undefined]);
expect(tab.queryDisplaySourceColumns).toEqual([undefined]);
});
it("resolves quoted mixed-case identifiers exactly, per the database's rules", async () => {
getConnectionConfig.mockReturnValue({ id: "pg-1", name: "PostgreSQL", db_type: "postgres", database: "app", query_timeout_secs: 30 });
const quotedOrders = [column("id", "订单ID", true), column("ID", "大写ID"), column("user_id", "下单用户")];
const quotedUsers = [column("id", "用户ID", true), column("Name", "大写Name")];
getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "orders" ? quotedOrders : quotedUsers));
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
tableAlias: "a",
selectStar: false,
columns: [
{ sourceName: "id", sourceNameQuoted: false, sourceKey: "a", resultName: "id", expression: "a.id" },
{ sourceName: "ID", sourceNameQuoted: true, sourceKey: "a", resultName: "ID", expression: 'a."ID"' },
{ sourceName: "Name", sourceNameQuoted: true, sourceKey: "b", resultName: "Name", expression: 'b."Name"' },
],
sources: [
{ key: "a", tableName: "orders", alias: "a" },
{ key: "b", tableName: "users", alias: "b" },
],
multiSource: true,
allowInsertDelete: false,
},
});
executeMulti.mockResolvedValue([
{
columns: ["id", "ID", "Name"],
rows: [[1, 9, "Alice"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "Query");
await store.executeTabSql(tabId, 'SELECT a.id, a."ID", b."Name" FROM orders a JOIN users b ON a.user_id = b.id');
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
// Quoted `"ID"` stays distinct from unquoted `id`; the global lower-casing
// of the previous map would have collapsed them.
expect(tab.resultColumnComments).toEqual(["订单ID", "大写ID", "大写Name"]);
expect(tab.queryDisplaySourceColumns).toEqual([
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "a", sourceColumn: "ID" },
{ sourceKey: "b", sourceColumn: "Name" },
]);
});
it("keeps duplicate result column names resolved in projection order", async () => {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
tableAlias: "a",
selectStar: false,
columns: [
{ sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" },
{ sourceName: "id", sourceKey: "b", resultName: "id", expression: "b.id" },
],
sources: [
{ key: "a", tableName: "orders", alias: "a" },
{ key: "b", tableName: "users", alias: "b" },
],
multiSource: true,
allowInsertDelete: false,
},
});
executeMulti.mockResolvedValue([
{
columns: ["id", "id_1"],
rows: [[1, 7]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
// The driver renames the second `id` to `id_1`; ordinal mapping still
// resolves column #1 to users.id instead of failing to match by name.
await store.executeTabSql(tabId, "SELECT a.id, b.id FROM orders a JOIN users b ON a.user_id = b.id");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
expect(tab.resultColumnComments).toEqual(["订单ID", "用户ID"]);
expect(tab.queryDisplaySourceColumns).toEqual([
{ sourceKey: "a", sourceColumn: "id" },
{ sourceKey: "b", sourceColumn: "id" },
]);
});
it("keeps single-source results free of multi-source comment fields", async () => {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "orders",
selectStar: false,
columns: [
{ sourceName: "id", sourceKey: "orders:0", resultName: "id", expression: "id" },
{ sourceName: "amount", sourceKey: "orders:0", resultName: "amount", expression: "amount" },
],
},
});
getColumns.mockResolvedValue(ordersColumns);
executeMulti.mockResolvedValue([
{
columns: ["id", "amount"],
rows: [[1, 9.99]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT id, amount FROM orders");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("orders"));
expect(tab.resultColumnComments).toBeUndefined();
expect(tab.queryDisplaySourceColumns).toBeUndefined();
expect(tab.querySourceColumns).toEqual(["id", "amount"]);
});
});
@@ -117,6 +117,20 @@ describe("queryStore switchTab", () => {
expect(queryStore.tabs[1].catalog).toBe("paimon_catalog");
});
it("clones result column comments when duplicating a query tab", () => {
const queryStore = useQueryStore();
const tabId = queryStore.createTab("pg-1", "app", undefined, "query", undefined, "SELECT 1");
const original = queryStore.tabs.find((tab) => tab.id === tabId)!;
original.resultColumnComments = ["identifier", "display name"];
queryStore.duplicateTab(tabId);
const duplicate = queryStore.tabs[1];
expect(Array.isArray(duplicate.resultColumnComments)).toBe(true);
expect(duplicate.resultColumnComments).toEqual(original.resultColumnComments);
expect(duplicate.resultColumnComments).not.toBe(original.resultColumnComments);
});
it("switches catalog and database as one query context", () => {
const queryStore = useQueryStore();
const tabId = queryStore.createTab("sr-1", "internal_db", undefined, "query");
+76 -4
View File
@@ -2,12 +2,12 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence";
import {
@@ -965,6 +965,8 @@ export const useQueryStore = defineStore("query", () => {
tab.resultEstimatedBytes = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.resultColumnComments = undefined;
tab.queryDisplaySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
tab.mongoEditTarget = undefined;
if (tab.mode === "query") tab.tableMeta = undefined;
@@ -1016,6 +1018,8 @@ export const useQueryStore = defineStore("query", () => {
run.resultEstimatedBytes = undefined;
run.queryAnalysis = undefined;
run.querySourceColumns = undefined;
run.resultColumnComments = undefined;
run.queryDisplaySourceColumns = undefined;
run.queryEditabilityReason = undefined;
run.mongoEditTarget = undefined;
run.tableMeta = undefined;
@@ -1058,6 +1062,8 @@ export const useQueryStore = defineStore("query", () => {
tab.resultEvicted = run.resultEvicted;
tab.queryAnalysis = run.queryAnalysis;
tab.querySourceColumns = run.querySourceColumns;
tab.resultColumnComments = run.resultColumnComments;
tab.queryDisplaySourceColumns = run.queryDisplaySourceColumns;
tab.queryEditabilityReason = run.queryEditabilityReason;
tab.mongoEditTarget = run.mongoEditTarget;
tab.tableMeta = run.tableMeta;
@@ -1217,6 +1223,8 @@ export const useQueryStore = defineStore("query", () => {
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
querySourceColumns: run.querySourceColumns,
resultColumnComments: run.resultColumnComments,
queryDisplaySourceColumns: run.queryDisplaySourceColumns,
queryEditabilityReason: run.queryEditabilityReason,
tableMeta: run.tableMeta,
resultPageSql: run.resultPageSql,
@@ -1296,6 +1304,8 @@ export const useQueryStore = defineStore("query", () => {
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
resultColumnComments: tab.resultColumnComments,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget,
tableMeta: tab.tableMeta,
@@ -1394,6 +1404,8 @@ export const useQueryStore = defineStore("query", () => {
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
querySourceColumns: tab.querySourceColumns,
resultColumnComments: tab.resultColumnComments,
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
queryEditabilityReason: tab.queryEditabilityReason,
mongoEditTarget: tab.mongoEditTarget,
tableMeta: tab.tableMeta,
@@ -1633,7 +1645,7 @@ export const useQueryStore = defineStore("query", () => {
const tab: QueryTab = {
id,
title: title || `query_${tabs.value.length + 1}`,
customTitle: mode === "query" && !!title ? true : undefined,
customTitle: mode === "query" && title ? true : undefined,
forceWordWrap: options.forceWordWrap,
connectionId,
database,
@@ -2685,6 +2697,8 @@ export const useQueryStore = defineStore("query", () => {
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
resultColumnComments: original.resultColumnComments ? [...original.resultColumnComments] : undefined,
queryDisplaySourceColumns: original.queryDisplaySourceColumns ? [...original.queryDisplaySourceColumns] : undefined,
queryEditabilityReason: original.queryEditabilityReason,
resultEvicted: undefined,
whereInput: original.whereInput,
@@ -3380,7 +3394,7 @@ export const useQueryStore = defineStore("query", () => {
return producedResult;
}
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta">;
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta" | "resultColumnComments" | "queryDisplaySourceColumns">;
type LoadedEditableSource = {
source: EditableQuerySource;
@@ -3395,6 +3409,38 @@ export const useQueryStore = defineStore("query", () => {
writeSchema?: string;
};
/**
* Resolve multi-source result columns (by projection ordinal) back to exactly
* one base column per source, then surface the resolved column comments and a
* display-only result->source mapping. Reuses the same database-aware binder
* as the editability analysis, so `name AS username` (uniquely resolvable
* unqualified alias) maps back to its physical column and quoted mixed-case
* identifiers keep exact casing. Ambiguous or unresolved columns yield
* `undefined` (no comment) instead of first-source-wins on a shared name.
*/
function resolveMultiSourceColumnInfo(dbType: string, analysis: EditableQueryInfo, resultColumns: string[], loadedSources: LoadedEditableSource[]): { comments: Array<string | undefined>; mapping: Array<QueryResultSourceColumnRef | undefined> } {
const refs = resolveSourceColumnsByOrdinal(
dbType,
analysis,
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
resultColumns.length,
);
const comments: Array<string | undefined> = [];
const mapping: Array<QueryResultSourceColumnRef | undefined> = [];
for (const ref of refs) {
if (!ref) {
comments.push(undefined);
mapping.push(undefined);
continue;
}
const loaded = loadedSources.find((entry) => entry.source.key === ref.sourceKey);
const comment = loaded?.tableMeta.columns.find((column) => column.name === ref.sourceColumn)?.comment?.trim();
comments.push(comment || undefined);
mapping.push(ref);
}
return { comments, mapping };
}
function canInsertIntoEditableQuerySource(tab: QueryTab, databaseType: DatabaseType | undefined, loaded: LoadedEditableSource, sourceColumns: readonly (string | undefined)[] | undefined): boolean {
if (!canInsertTableRows(databaseType) || !sourceColumns?.length || !sourceColumns.every(Boolean)) return false;
const knownTableType =
@@ -3418,6 +3464,8 @@ export const useQueryStore = defineStore("query", () => {
tab.queryEditabilityReason = patch.queryEditabilityReason;
tab.mongoEditTarget = undefined;
tab.tableMeta = patch.tableMeta;
tab.resultColumnComments = patch.resultColumnComments;
tab.queryDisplaySourceColumns = patch.queryDisplaySourceColumns;
}
function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget {
@@ -3733,12 +3781,20 @@ export const useQueryStore = defineStore("query", () => {
};
}
// Multi-source results cannot carry a single tableMeta, but every source
// table's metadata is already loaded. Surface per-ordinal column comments
// and a display-only result->source mapping so the data grid can still
// show comments for joined results (fixes #2129 / #6352).
const multiSourceInfo = loadedSources.length > 1 ? resolveMultiSourceColumnInfo(dbType, analysis, tab.result.columns, loadedSources) : undefined;
if (candidates.length === 0) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: loadedSources.some((loaded) => loaded.tableMeta.primaryKeys.length > 0) ? "primary-key-not-returned" : "no-primary-key",
tableMeta: undefined,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
}
@@ -3748,6 +3804,8 @@ export const useQueryStore = defineStore("query", () => {
querySourceColumns: undefined,
queryEditabilityReason: "complex-source",
tableMeta: undefined,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
}
@@ -3763,6 +3821,8 @@ export const useQueryStore = defineStore("query", () => {
querySourceColumns: target.sourceColumns,
queryEditabilityReason: undefined,
tableMeta: target.tableMeta,
resultColumnComments: multiSourceInfo?.comments,
queryDisplaySourceColumns: multiSourceInfo?.mapping,
};
} catch (err) {
console.error("[DBX] ERROR fetching columns for query metadata:", err);
@@ -4079,6 +4139,8 @@ export const useQueryStore = defineStore("query", () => {
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
current.tableMeta = undefined;
@@ -4461,6 +4523,8 @@ export const useQueryStore = defineStore("query", () => {
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = mongoCommands.length === 1 ? mongoEditTarget : undefined;
current.tableMeta = undefined;
@@ -4526,6 +4590,8 @@ export const useQueryStore = defineStore("query", () => {
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
current.tableMeta = undefined;
@@ -4908,6 +4974,8 @@ export const useQueryStore = defineStore("query", () => {
}
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.resultColumnComments = undefined;
current.queryDisplaySourceColumns = undefined;
current.queryEditabilityReason = undefined;
current.mongoEditTarget = undefined;
if (current.mode !== "data") current.tableMeta = undefined;
@@ -5388,6 +5456,8 @@ export const useQueryStore = defineStore("query", () => {
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.resultColumnComments = undefined;
tab.queryDisplaySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
tab.mongoEditTarget = undefined;
syncActiveResultRunFromDisplayed(tab);
@@ -5511,6 +5581,8 @@ export const useQueryStore = defineStore("query", () => {
tab.queryAnalysis = snapshot.queryAnalysis;
tab.querySourceColumns = snapshot.querySourceColumns;
tab.resultColumnComments = snapshot.resultColumnComments;
tab.queryDisplaySourceColumns = snapshot.queryDisplaySourceColumns;
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
tab.mongoEditTarget = snapshot.mongoEditTarget;
// Data tab 的结果快照可能早于最近一次结构变更。已持有真实元数据时,
+26
View File
@@ -775,6 +775,11 @@ export interface SpatialColumn {
srid: number | null;
}
export interface QueryResultSourceColumnRef {
sourceKey: string;
sourceColumn: string;
}
export interface QueryResultRun {
id: string;
title: string;
@@ -815,6 +820,8 @@ export interface QueryResultRun {
resultEvicted?: boolean;
queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"];
resultColumnComments?: QueryTab["resultColumnComments"];
queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"];
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
tableMeta?: QueryTab["tableMeta"];
@@ -1279,6 +1286,25 @@ export interface QueryTab {
}[];
};
querySourceColumns?: Array<string | undefined>;
/**
* Column comments for a multi-source query result (e.g. JOIN), indexed by
* result-column ordinal (projection order). Each entry is the comment of the
* single base column that result column resolves to; `undefined` when the
* column is ambiguous (e.g. an unqualified name present in several sources)
* or cannot be resolved back to a base column, so the grid shows no comment
* instead of a wrong one. Populated even when the result is not editable
* (e.g. multi-table JOIN), so joined results still show column comments.
*/
resultColumnComments?: Array<string | undefined>;
/**
* Display-only result-column to source mapping for multi-source results,
* indexed by result-column ordinal. Each entry carries the source identity
* (sourceKey + canonical source column name), so comments resolve per source
* instead of first-source-wins on name clashes. Unlike querySourceColumns it
* is also populated for multi-source results that are not editable, and must
* never be used for row identity or editing.
*/
queryDisplaySourceColumns?: Array<QueryResultSourceColumnRef | undefined>;
queryEditabilityReason?: "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable";
mongoEditTarget?: {
collection: string;