diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e6b11ed491..9c9a547272 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -175,7 +175,7 @@ import { import { adjacentDataGridDetailIndex, type DataGridDetailNavigationDelta } from "@/lib/dataGrid/dataGridDetailNavigation"; import { applyColumnFormatter, - buildColumnFormatterKey, + columnFormatterKeys, defaultIoTDBTimestampFormatter, DataGridDateTimePatterns, displayTimeZoneOption, @@ -1521,7 +1521,7 @@ function openCompactLocalFilter(colIdx: number, mode: LocalFilterMode = "local") }); } -function compactColumnActionMenuItems(columnName: string, columnIndex: number) { +function compactColumnActionMenuItems(columnIndex: number) { return createDataGridCompactColumnActionItems({ labels: { formatter: t("grid.columnFormatter"), @@ -1530,7 +1530,7 @@ function compactColumnActionMenuItems(columnName: string, columnIndex: number) { serverFilter: t("grid.databaseValueFilter"), }, icons: { formatter: Code2, clearFormatter: Eraser, filter: Filter, database: Database }, - formatterAvailable: !!formatterKeyForColumn(columnName), + formatterAvailable: !!formatterKeyForColumn(columnIndex), formatterActive: columnHasFormatter(columnIndex), serverFilterAvailable: canUseServerColumnFilter.value, }); @@ -1567,23 +1567,36 @@ function closeLocalFilter() { resetServerFilterState(); } -function formatterKeyForColumn(column: string): string | null { - if (!props.connectionId || !props.tableMeta) return null; - return buildColumnFormatterKey({ +function formatterKeysForColumn(columnIndex: number): string[] { + const resultColumn = props.result.columns[columnIndex]; + if (!props.connectionId || !resultColumn) return []; + return columnFormatterKeys({ connectionId: props.connectionId, database: props.database, - schema: props.tableMeta.schema, - tableName: props.tableMeta.tableName, - column, + schema: props.schema, + databaseType: resolvedDatabaseType.value, + resultColumn, + sourceColumn: props.sourceColumns?.[columnIndex], + displaySource: props.queryDisplaySourceColumns?.[columnIndex], + tableMeta: props.tableMeta, }); } +function formatterKeyForColumn(columnIndex: number): string | null { + return formatterKeysForColumn(columnIndex)[0] ?? null; +} + +function savedColumnFormatterEntry(columnIndex: number): { key: string; formatter: ColumnFormatterConfig } | undefined { + for (const key of formatterKeysForColumn(columnIndex)) { + const formatter = settingsStore.editorSettings.columnFormatters[key]; + if (formatter) return { key, formatter }; + } + return undefined; +} + function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { - const column = props.result.columns[columnIndex]; - if (!column) return undefined; - const key = formatterKeyForColumn(column); const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type; - const savedFormatter = key ? settingsStore.editorSettings.columnFormatters[key] : undefined; + const savedFormatter = savedColumnFormatterEntry(columnIndex)?.formatter; const configured = resolveColumnFormatter(savedFormatter, settingsStore.editorSettings.customColumnFormatters, { pattern: settingsStore.editorSettings.globalDateTimeDisplayFormat, columnType, @@ -1593,10 +1606,7 @@ function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined } function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { - const column = props.result.columns[columnIndex]; - if (!column) return undefined; - const key = formatterKeyForColumn(column); - return key ? settingsStore.editorSettings.columnFormatters[key] : undefined; + return savedColumnFormatterEntry(columnIndex)?.formatter; } function columnHasFormatter(columnIndex: number): boolean { @@ -1880,8 +1890,7 @@ function handleColumnFormatterOpenChange(value: boolean, columnIndex: number) { } async function saveColumnFormatter(columnIndex: number) { - const column = props.result.columns[columnIndex]; - const key = column ? formatterKeyForColumn(column) : null; + const key = formatterKeyForColumn(columnIndex); if (!key) return; try { let formatter = currentFormatterDraft(); @@ -1909,10 +1918,12 @@ async function saveColumnFormatter(columnIndex: number) { } function clearColumnFormatter(columnIndex: number) { - const column = props.result.columns[columnIndex]; - const key = column ? formatterKeyForColumn(column) : null; - if (!key) return; - settingsStore.updateColumnFormatter(key, undefined); + // Clear every candidate key: MySQL-family columns can carry both the shared + // empty-schema spelling and the legacy query-side mirrored-schema spelling, + // and either surface must leave the column unformatted everywhere. + const keys = formatterKeysForColumn(columnIndex); + if (!keys.length) return; + for (const key of keys) settingsStore.updateColumnFormatter(key, undefined); closeColumnFormatter(); } @@ -13083,6 +13094,8 @@ function openGridSnapshot() { :column-unique-index-label="t('grid.columnUniqueIndex')" :column-regular-index-label="t('grid.columnRegularIndex')" :column-index-kind="showIndexIndicatorsInHeader ? columnIndexMap.get(columnIndexNameKey(col.name)) : undefined" + :formatter-active="columnHasFormatter(col.actualColIdx)" + :formatter-label="t('grid.columnFormatterActive')" @pointerdown="startColumnHeaderDrag(col.visibleColIdx, $event)" @click-capture="onHeaderClickCapture" @click="onHeaderClick(col.visibleColIdx, $event)" @@ -13130,7 +13143,7 @@ function openGridSnapshot() { diff --git a/apps/desktop/src/components/grid/DataGridColumnHeader.vue b/apps/desktop/src/components/grid/DataGridColumnHeader.vue index c4566443c4..3d556ff871 100644 --- a/apps/desktop/src/components/grid/DataGridColumnHeader.vue +++ b/apps/desktop/src/components/grid/DataGridColumnHeader.vue @@ -37,6 +37,9 @@ const props = defineProps<{ columnRegularIndexLabel: string; /** 该列的索引类型,undefined 时不显示标识 */ columnIndexKind?: ColumnIndexKind; + /** 该列当前展示值应用了格式化规则。 */ + formatterActive?: boolean; + formatterLabel?: string; }>(); function columnIndexText(kind: ColumnIndexKind): string { @@ -75,7 +78,9 @@ const emit = defineEmits<{ - {{ name }} + + {{ name }} + {{ columnType }} {{ columnComment @@ -113,6 +118,7 @@ const emit = defineEmits<{ + F
diff --git a/apps/desktop/src/components/grid/__tests__/DataGridColumnFormatterKeys.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridColumnFormatterKeys.spec.ts new file mode 100644 index 0000000000..62c60f19b6 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridColumnFormatterKeys.spec.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +function functionSource(name: string): string { + const start = dataGridSource.indexOf(`function ${name}`); + expect(start).toBeGreaterThanOrEqual(0); + const end = dataGridSource.indexOf("\nfunction ", start + 1); + return dataGridSource.slice(start, end < 0 ? undefined : end); +} + +describe("DataGrid column formatter keys", () => { + it("derives every key list from the shared canonical key builder", () => { + const keysSource = functionSource("formatterKeysForColumn"); + expect(keysSource).toContain("return columnFormatterKeys({"); + expect(keysSource).toContain("databaseType: resolvedDatabaseType.value"); + expect(keysSource).toContain("displaySource: props.queryDisplaySourceColumns?.[columnIndex]"); + // Saves keep writing the single canonical keys[0] spelling. + const saveSource = functionSource("saveColumnFormatter"); + expect(saveSource).toContain("const key = formatterKeyForColumn(columnIndex);"); + expect(saveSource).toContain("settingsStore.updateColumnFormatter(key, formatter);"); + }); + + it("clears every candidate key so either surface leaves the column unformatted", () => { + const clearSource = functionSource("clearColumnFormatter"); + expect(clearSource).toContain("const keys = formatterKeysForColumn(columnIndex);"); + expect(clearSource).toContain("if (!keys.length) return;"); + expect(clearSource).toContain("for (const key of keys) settingsStore.updateColumnFormatter(key, undefined);"); + }); +}); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 81c789fa92..da70a80db5 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -413,6 +413,28 @@ describe("DataGridPagination", () => { }); describe("DataGridColumnHeader", () => { + it("uses a compact formatter marker with an accessible explanation", () => { + const mounted = mountComponent(DataGridColumnHeader, { + name: "created_at", + actualColumnIndex: 0, + visibleColumnIndex: 0, + formatterActive: true, + formatterLabel: "Formatted display is active", + copyColumnNameLabel: "copy", + columnNameLabel: "name", + columnTypeLabel: "type", + columnCommentLabel: "comment", + }); + const indicator = findOne(mounted.root, (node) => node.props["data-column-formatter-indicator"] === ""); + const trigger = findOne(mounted.root, (node) => node.props["data-column-tooltip-trigger"] === ""); + + expect(hostText(indicator)).toBe("F"); + expect(String(indicator.props.class)).toContain("h-4 w-4"); + expect(indicator.props.title).toBe("Formatted display is active"); + expect(indicator.props["aria-label"]).toBe("Formatted display is active"); + expect(indicator.parent).not.toBe(trigger); + }); + it("limits the metadata tooltip trigger to the column text block", () => { const mounted = mountComponent(DataGridColumnHeader, { name: "status", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b7ccb90525..a929ca1369 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1776,6 +1776,7 @@ export default { databaseValueFilter: "Database value filter", databaseValueFilterFor: "Database Values For '{column}'", columnFormatter: "Column formatter", + columnFormatterActive: "Formatted display is active", columnFormatterFor: "Formatter for '{column}'", columnFormatterHint: "Formats display only; raw values stay unchanged.", formatterType: "Type", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4b51d45971..0bb761a8f1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1656,6 +1656,7 @@ export default withEnglishFallback({ databaseValueFilter: "Filtro de valores de base de datos", databaseValueFilterFor: "Valores de base de datos para '{column}'", columnFormatter: "Formateador de columna", + columnFormatterActive: "La visualización con formato está activa", columnFormatterFor: "Formateador para '{column}'", columnFormatterHint: "Solo formatea la visualización; los valores sin procesar no cambian.", formatterType: "Tipo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b32a6a2a69..94931de73e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1654,6 +1654,7 @@ export default withEnglishFallback({ databaseValueFilter: "Filtro valori database", databaseValueFilterFor: "Valori database per '{column}'", columnFormatter: "Formattatore colonna", + columnFormatterActive: "La visualizzazione formattata è attiva", columnFormatterFor: "Formattatore per '{column}'", columnFormatterHint: "Formatta solo la visualizzazione; i valori effettivi rimangono invariati.", formatterType: "Tipo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 963652b603..c8f8c4f547 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1666,6 +1666,7 @@ export default withEnglishFallback({ databaseValueFilter: "データベース値フィルター", databaseValueFilterFor: "'{column}'のデータベース値", columnFormatter: "列フォーマッター", + columnFormatterActive: "この列には表示形式が適用されています", columnFormatterFor: "'{column}'のフォーマッター", columnFormatterHint: "表示のみをフォーマットし、実際の値は変更されません。", formatterType: "タイプ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index a1b52ecdda..561a5c9eea 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1649,6 +1649,7 @@ export default withEnglishFallback({ databaseValueFilter: "데이터베이스 값 필터", databaseValueFilterFor: "'{column}' 데이터베이스 값", columnFormatter: "컬럼 포매터", + columnFormatterActive: "이 열에 서식이 적용되어 있습니다", columnFormatterFor: "'{column}' 포매터", columnFormatterHint: "표시만 서식 지정하며 원시 값은 변경되지 않습니다.", formatterType: "유형", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a47b52922b..c23afa757e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1656,6 +1656,7 @@ export default withEnglishFallback({ databaseValueFilter: "Filtro de valores do banco de dados", databaseValueFilterFor: "Valores do banco de dados para '{column}'", columnFormatter: "Formatador de coluna", + columnFormatterActive: "A exibição formatada está ativa", columnFormatterFor: "Formatador para '{column}'", columnFormatterHint: "Formata apenas a exibição; os valores brutos permanecem inalterados.", formatterType: "Tipo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 086dfd0c16..207f2b641b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1700,6 +1700,7 @@ export default withEnglishFallback({ databaseValueFilter: "数据库值筛选", databaseValueFilterFor: "数据库值筛选“{column}”", columnFormatter: "列格式化", + columnFormatterActive: "该列已应用格式化展示", columnFormatterFor: "格式化“{column}”", columnFormatterHint: "仅改变显示效果,原始值保持不变。", formatterType: "类型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 11350c2f8b..2a544127ef 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1654,6 +1654,7 @@ export default withEnglishFallback({ databaseValueFilter: "資料庫值篩選", databaseValueFilterFor: "資料庫值篩選「{column}」", columnFormatter: "欄位格式設定", + columnFormatterActive: "此欄位已套用格式化顯示", columnFormatterFor: "格式化「{column}」", columnFormatterHint: "僅改變顯示效果,原始值保持不變。", formatterType: "類型", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts index b1c39bf7b5..d5419b5661 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts @@ -1,5 +1,86 @@ import { describe, expect, it } from "vitest"; -import { applyColumnFormatter, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeColumnFormatter, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter"; +import { applyColumnFormatter, columnFormatterKeys, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeColumnFormatter, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter"; + +describe("columnFormatterKeys", () => { + // MySQL query metadata mirrors the database into the schema slot, while the + // table view has always keyed formatters with an empty schema. + const mysqlQueryColumn = { + connectionId: "conn1", + database: "app", + databaseType: "mysql", + resultColumn: "created_at", + displaySource: { database: "app", schema: "app", tableName: "users", sourceColumn: "created_at" }, + }; + const mysqlTableColumn = { + connectionId: "conn1", + database: "app", + databaseType: "mysql", + resultColumn: "created_at", + tableMeta: { database: "app", tableName: "users" }, + }; + + it("writes MySQL query-result saves under the table-view empty-schema key", () => { + const keys = columnFormatterKeys(mysqlQueryColumn); + expect(keys[0]).toBe("conn1::app::::users::created_at"); + // The mirrored spelling stays readable so older saves keep applying. + expect(keys).toContain("conn1::app::app::users::created_at"); + }); + + it("resolves a MySQL formatter saved from a query result in the table view", () => { + const queryKeys = columnFormatterKeys(mysqlQueryColumn); + const tableKeys = columnFormatterKeys(mysqlTableColumn); + // A save writes keys[0]; the table-view read list must include that key. + expect(tableKeys).toContain(queryKeys[0]!); + expect(tableKeys[0]).toBe(queryKeys[0]); + // Clearing from the table view removes the legacy mirrored save too. + expect(tableKeys).toContain("conn1::app::app::users::created_at"); + }); + + it("keeps the physical schema as the write key for schema-aware dialects", () => { + const keys = columnFormatterKeys({ + connectionId: "conn1", + database: "app", + databaseType: "postgres", + resultColumn: "created_at", + displaySource: { database: "app", schema: "public", tableName: "users", sourceColumn: "created_at" }, + }); + expect(keys).toEqual(["conn1::app::public::users::created_at"]); + }); + + it("returns no keys without a physical table identity", () => { + expect(columnFormatterKeys({ connectionId: "conn1", database: "app", databaseType: "mysql", resultColumn: "total" })).toEqual([]); + expect( + columnFormatterKeys({ + connectionId: "conn1", + database: "app", + databaseType: "mysql", + resultColumn: "total", + // Cached legacy mapping without a resolvable physical table. + displaySource: { sourceColumn: "total" }, + }), + ).toEqual([]); + }); + + it("keys writes by the physical source column, not an alias", () => { + const keys = columnFormatterKeys({ ...mysqlQueryColumn, resultColumn: "createdAt" }); + expect(keys[0]).toBe("conn1::app::::users::created_at"); + expect(keys.every((key) => key.endsWith("::created_at"))).toBe(true); + }); + + it("canonicalizes writes for unresolved query columns backed by mirrored tab metadata", () => { + // A query column without a per-ordinal source mapping still inherits the + // tab's tableMeta, whose MySQL schema slot mirrors the database. + const keys = columnFormatterKeys({ + connectionId: "conn1", + database: "app", + databaseType: "mysql", + resultColumn: "created_at", + tableMeta: { database: "app", schema: "app", tableName: "users" }, + }); + expect(keys[0]).toBe("conn1::app::::users::created_at"); + expect(keys).toContain("conn1::app::app::users::created_at"); + }); +}); describe("normalizeColumnFormatter", () => { it("preserves a structured manual reference filter", () => { diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index b53630f021..a2c787f732 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -148,6 +148,89 @@ export function buildColumnFormatterKey(parts: ColumnFormatterKeyParts): string return [parts.connectionId, parts.database ?? "", parts.schema ?? "", parts.tableName, parts.column].join("::"); } +export interface ColumnFormatterKeyContext { + connectionId: string; + database?: string; + schema?: string; + databaseType?: string; + /** Result-column label (may be an alias). */ + resultColumn: string; + /** Physical source column label when the grid already knows it. */ + sourceColumn?: string; + /** Resolved physical source identity of a query result column, when known. */ + displaySource?: { + database?: string; + schema?: string; + tableName?: string; + sourceColumn?: string; + }; + tableMeta?: { + database?: string; + schema?: string; + tableName: string; + }; +} + +// Ordered formatter-key candidates for one grid column. keys[0] is the write +// key: always the physical source identity, never the current tab namespace, +// so a same-named table in another database or schema must not inherit this +// column's formatter. Remaining keys are read/clear fallbacks for configs +// saved by other surfaces or older versions. +// +// MySQL-family query metadata mirrors the database into the schema slot while +// table-view keys have always used an empty schema. Keep the shared +// empty-schema key first so a formatter saved from a query result is written +// to (and read by) the table view too, and retain the mirrored spelling only +// as a read fallback for configs saved by older versions. +export function columnFormatterKeys(context: ColumnFormatterKeyContext): string[] { + const { connectionId, database, schema, databaseType, resultColumn } = context; + const displaySource = context.displaySource; + // A joined-result source key is only meaningful within one query. Do not + // guess a formatter key for cached legacy mappings that lack a physical table. + if (displaySource && !displaySource.tableName) return []; + + const tableMeta = displaySource?.tableName + ? { + database: displaySource.database, + schema: displaySource.schema, + tableName: displaySource.tableName, + } + : context.tableMeta; + if (!tableMeta?.tableName) return []; + + const sourceColumn = displaySource?.sourceColumn || context.sourceColumn || resultColumn; + const keys = new Set(); + const primaryDatabase = tableMeta.database?.trim() || database; + const primarySchema = tableMeta.schema ?? schema; + const isMysql = databaseType === "mysql"; + const mirroredSchema = isMysql ? primaryDatabase : undefined; + const addKey = (keyDatabase: string | undefined, keySchema: string | undefined, column: string) => { + keys.add( + buildColumnFormatterKey({ + connectionId, + database: keyDatabase, + schema: keySchema, + tableName: tableMeta.tableName, + column, + }), + ); + }; + + if (isMysql) addKey(primaryDatabase, "", sourceColumn); + addKey(primaryDatabase, primarySchema, sourceColumn); + if (displaySource?.tableName) return [...keys]; + + const databases = [...new Set([primaryDatabase, database, context.tableMeta?.database])]; + const schemas = [...new Set([primarySchema, schema, context.tableMeta?.schema, "", mirroredSchema])]; + const columns = [...new Set([sourceColumn, resultColumn])]; + for (const keyDatabase of databases) { + for (const keySchema of schemas) { + for (const column of columns) addKey(keyDatabase, keySchema, column); + } + } + return [...keys]; +} + export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig | undefined { if (!value || typeof value !== "object") return undefined; const config = value as Record; diff --git a/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts index cd69e4f82d..54ea63d3ed 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts @@ -115,7 +115,7 @@ describe("queryStore grouped-result column comments", () => { // Only the directly projected physical column carries its comment; the // aggregate expression resolves to nothing and must not guess. expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, undefined]); }); it("resolves aliased grouped columns back to their physical column (T2)", async () => { @@ -139,7 +139,7 @@ describe("queryStore grouped-result column comments", () => { await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); expect(tab.queryEditabilityReason).toBe("aggregation"); expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, undefined]); }); it("keeps comments for HAVING queries (T3)", async () => { @@ -186,7 +186,7 @@ describe("queryStore grouped-result column comments", () => { await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); expect(tab.queryEditabilityReason).toBe("aggregation"); expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "department" }, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, undefined]); }); it("maps multiple grouped physical columns (T5)", async () => { @@ -236,7 +236,7 @@ describe("queryStore grouped-result column comments", () => { expect(tab.queryDisplaySourceColumns).toEqual([undefined, undefined]); }); - it("keeps ordinary editable queries free of the new display fields (T10)", async () => { + it("stores physical source identities for ordinary editable queries (T10)", async () => { analyzeEditableQueryEditability.mockResolvedValue({ editable: true, analysis: { @@ -268,7 +268,10 @@ describe("queryStore grouped-result column comments", () => { const tab = store.tabs.find((item) => item.id === tabId)!; await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("users")); expect(tab.resultColumnComments).toBeUndefined(); - expect(tab.queryDisplaySourceColumns).toBeUndefined(); + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "users:0", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "users:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, + ]); expect(tab.querySourceColumns).toEqual(["id", "department"]); }); diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts index ad5d3fe11c..2f238532dd 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts @@ -137,12 +137,13 @@ describe("queryStore multi-source result column comments", () => { // 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. + // The display mapping carries both source identity and physical table + // identity per ordinal, so display-only features can share table settings. expect(tab.queryDisplaySourceColumns).toEqual([ - { sourceKey: "a", sourceColumn: "id" }, - { sourceKey: "a", sourceColumn: "user_id" }, - { sourceKey: "b", sourceColumn: "id" }, - { sourceKey: "b", sourceColumn: "name" }, + { sourceKey: "a", sourceColumn: "id", database: "app", schema: "app", tableName: "orders" }, + { sourceKey: "a", sourceColumn: "user_id", database: "app", schema: "app", tableName: "orders" }, + { sourceKey: "b", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "b", sourceColumn: "name", database: "app", schema: "app", tableName: "users" }, ]); }); @@ -187,8 +188,8 @@ describe("queryStore multi-source result column comments", () => { await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); expect(tab.resultColumnComments).toEqual(["订单ID", "用户名"]); expect(tab.queryDisplaySourceColumns).toEqual([ - { sourceKey: "a", sourceColumn: "id" }, - { sourceKey: "b", sourceColumn: "name" }, + { sourceKey: "a", sourceColumn: "id", database: "app", schema: "app", tableName: "orders" }, + { sourceKey: "b", sourceColumn: "name", database: "app", schema: "app", tableName: "users" }, ]); }); @@ -279,9 +280,9 @@ describe("queryStore multi-source result column comments", () => { // 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" }, + { sourceKey: "a", sourceColumn: "id", database: "app", schema: undefined, tableName: "orders" }, + { sourceKey: "a", sourceColumn: "ID", database: "app", schema: undefined, tableName: "orders" }, + { sourceKey: "b", sourceColumn: "Name", database: "app", schema: undefined, tableName: "users" }, ]); }); @@ -326,12 +327,12 @@ describe("queryStore multi-source result column comments", () => { 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" }, + { sourceKey: "a", sourceColumn: "id", database: "app", schema: "app", tableName: "orders" }, + { sourceKey: "b", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, ]); }); - it("keeps single-source results free of multi-source comment fields", async () => { + it("stores physical source identities for single-source result columns", async () => { analyzeEditableQueryEditability.mockResolvedValue({ editable: true, analysis: { @@ -363,7 +364,10 @@ describe("queryStore multi-source result column comments", () => { 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.queryDisplaySourceColumns).toEqual([ + { sourceKey: "orders:0", sourceColumn: "id", database: "app", schema: "app", tableName: "orders" }, + { sourceKey: "orders:0", sourceColumn: "amount", database: "app", schema: "app", tableName: "orders" }, + ]); expect(tab.querySourceColumns).toEqual(["id", "amount"]); }); diff --git a/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts index d8b205d015..6fbffacaee 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts @@ -102,7 +102,7 @@ describe("queryStore MySQL grouped-result editing", () => { expect(tab.querySourceColumns).toEqual(["id", "department", undefined]); expect(tab.tableMeta).toMatchObject({ tableName: "users", primaryKeys: ["id"] }); expect(tab.resultColumnComments).toEqual(["user id", "department", undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, { sourceKey: "u:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, undefined]); expect(listIndexes).not.toHaveBeenCalled(); }); @@ -124,7 +124,12 @@ describe("queryStore MySQL grouped-result editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); expect(tab.querySourceColumns).toEqual(["id", "department", undefined, undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, { sourceKey: "o:1", sourceColumn: "user_id" }, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "u:0", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "u:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "o:1", sourceColumn: "user_id", database: "app", schema: "app", tableName: "orders" }, + undefined, + ]); }); it("keeps a non-root source read-only even when its complete primary key is grouped", async () => { @@ -199,7 +204,14 @@ describe("queryStore MySQL grouped-result editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); expect(tab.querySourceColumns).toEqual(["id", "department", undefined, undefined, undefined, undefined]); - expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, { sourceKey: "u:0", sourceColumn: "display_label" }, { sourceKey: "u:0", sourceColumn: "stored_label" }, undefined, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "u:0", sourceColumn: "id", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "u:0", sourceColumn: "department", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "u:0", sourceColumn: "display_label", database: "app", schema: "app", tableName: "users" }, + { sourceKey: "u:0", sourceColumn: "stored_label", database: "app", schema: "app", tableName: "users" }, + undefined, + undefined, + ]); }); it("accepts a complete composite primary key only when it is the exact grouping key", async () => { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index c559cad117..f0da38081a 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3844,7 +3844,12 @@ export const useQueryStore = defineStore("query", () => { 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); + mapping.push({ + ...ref, + database: loaded?.tableMeta.database, + schema: loaded?.tableMeta.schema, + tableName: loaded?.tableMeta.tableName, + }); } return { comments, mapping }; } @@ -4335,7 +4340,7 @@ export const useQueryStore = defineStore("query", () => { const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns); const syntheticRowIdProjection = hiddenPrimaryKeys.find((projection) => projection.sourceName.toUpperCase() === DBX_ROWID_COLUMN); const primaryKeys = loaded.tableMeta.primaryKeys.length === 0 && syntheticRowIdProjection ? [DBX_ROWID_COLUMN] : loaded.tableMeta.primaryKeys; - const keylessResultInfo = primaryKeys.length === 0 ? resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources) : undefined; + const displaySourceInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources); const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key); if (sourceColumns && syntheticRowIdProjection) { const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase()); @@ -4347,7 +4352,8 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: undefined, queryEditabilityReason: "no-primary-key", tableMeta: loaded.tableMeta, - resultColumnComments: keylessResultInfo?.comments, + resultColumnComments: displaySourceInfo.comments, + queryDisplaySourceColumns: displaySourceInfo.mapping, }; } @@ -4358,6 +4364,7 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: undefined, queryEditabilityReason: "primary-key-not-returned", tableMeta: loaded.tableMeta, + queryDisplaySourceColumns: displaySourceInfo.mapping, }; } @@ -4367,6 +4374,7 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: undefined, queryEditabilityReason: "aliased-columns", tableMeta: loaded.tableMeta, + queryDisplaySourceColumns: displaySourceInfo.mapping, }; } @@ -4375,7 +4383,8 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: sourceColumns, queryEditabilityReason: undefined, tableMeta: primaryKeys === loaded.tableMeta.primaryKeys ? loaded.tableMeta : { ...loaded.tableMeta, primaryKeys }, - resultColumnComments: keylessResultInfo?.comments, + resultColumnComments: primaryKeys.length === 0 ? displaySourceInfo.comments : undefined, + queryDisplaySourceColumns: displaySourceInfo.mapping, }; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index b6ba078266..c26280ebc1 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -775,6 +775,10 @@ export interface SpatialColumn { export interface QueryResultSourceColumnRef { sourceKey: string; sourceColumn: string; + /** Physical source identity for display-only features such as column formatters. */ + database?: string; + schema?: string; + tableName?: string; } export interface QueryResultRun {