Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions apps/desktop/src/components/grid/DataGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -335,6 +335,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;
Expand Down Expand Up @@ -575,6 +591,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]);
}

Expand Down Expand Up @@ -2031,7 +2052,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/layout/ContentArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,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"
Expand Down
176 changes: 176 additions & 0 deletions apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts
Original file line number Diff line number Diff line change
@@ -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" }]);
});
});
54 changes: 54 additions & 0 deletions apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading