fix(grid): show comments for joined query results
This commit is contained in:
1 parent
d5f18034f9
commit
64e5e88c27
11 files changed
+867
-9
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
+369
@@ -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");
|
||||
|
||||
@@ -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 的结果快照可能早于最近一次结构变更。已持有真实元数据时,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user