feat(grid): inherit physical column formatting in query results

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-01 11:19:46 +08:00
1 parent 8be503bf40
commit c7cea5a047
19 files changed
+329 -53

No files matched your search

+38 -25
View File
@@ -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() {
</LightDropdownMenu>
<LightDropdownMenu
v-if="compactColumnHeaderActions"
:items="compactColumnActionMenuItems(col.name, col.actualColIdx)"
:items="compactColumnActionMenuItems(col.actualColIdx)"
:open="headerActionMenuOpenColumn === col.actualColIdx"
check-position="none"
align="end"
@@ -13163,7 +13176,7 @@ function openGridSnapshot() {
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-foreground"
:class="columnHasFormatter(col.actualColIdx) ? 'text-primary opacity-100' : 'opacity-80'"
:disabled="!formatterKeyForColumn(col.name)"
:disabled="!formatterKeyForColumn(col.actualColIdx)"
:title="t('grid.columnFormatter')"
@click.stop
>
@@ -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<{
<Hash v-else-if="columnIndexKind && columnIndexKind !== 'none'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
<span data-column-tooltip-trigger class="flex min-w-0 flex-1 flex-col overflow-hidden">
<span class="min-w-0 truncate leading-4">{{ name }}</span>
<span class="flex min-w-0 items-center gap-1 leading-4">
<span class="min-w-0 truncate">{{ name }}</span>
</span>
<span v-if="showTypeLine" data-grid-header-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3" :class="[typeClass, { invisible: !columnType }]" :title="columnType || undefined" :aria-hidden="columnType ? undefined : true">{{ columnType }}</span>
<span v-if="showCommentLine" data-grid-header-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground" :class="{ invisible: !columnComment }" :title="columnComment || undefined" :aria-hidden="columnComment ? undefined : true">{{
columnComment
@@ -113,6 +118,7 @@ const emit = defineEmits<{
</div>
</template>
</LightTooltip>
<span v-if="formatterActive" data-column-formatter-indicator class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm bg-violet-600 text-[10px] font-bold leading-none text-white shadow-sm dark:bg-violet-500" :title="formatterLabel" :aria-label="formatterLabel">F</span>
<span data-column-header-actions class="contents"><slot name="actions" /></span>
</span>
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="emit('resizeStart', $event)" @click.stop.prevent @dblclick.stop="emit('autoFit')" />
@@ -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);");
});
});
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1666,6 +1666,7 @@ export default withEnglishFallback({
databaseValueFilter: "データベース値フィルター",
databaseValueFilterFor: "'{column}'のデータベース値",
columnFormatter: "列フォーマッター",
columnFormatterActive: "この列には表示形式が適用されています",
columnFormatterFor: "'{column}'のフォーマッター",
columnFormatterHint: "表示のみをフォーマットし、実際の値は変更されません。",
formatterType: "タイプ",
+1
View File
@@ -1649,6 +1649,7 @@ export default withEnglishFallback({
databaseValueFilter: "데이터베이스 값 필터",
databaseValueFilterFor: "'{column}' 데이터베이스 값",
columnFormatter: "컬럼 포매터",
columnFormatterActive: "이 열에 서식이 적용되어 있습니다",
columnFormatterFor: "'{column}' 포매터",
columnFormatterHint: "표시만 서식 지정하며 원시 값은 변경되지 않습니다.",
formatterType: "유형",
+1
View File
@@ -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",
+1
View File
@@ -1700,6 +1700,7 @@ export default withEnglishFallback({
databaseValueFilter: "数据库值筛选",
databaseValueFilterFor: "数据库值筛选“{column}”",
columnFormatter: "列格式化",
columnFormatterActive: "该列已应用格式化展示",
columnFormatterFor: "格式化“{column}”",
columnFormatterHint: "仅改变显示效果,原始值保持不变。",
formatterType: "类型",
+1
View File
@@ -1654,6 +1654,7 @@ export default withEnglishFallback({
databaseValueFilter: "資料庫值篩選",
databaseValueFilterFor: "資料庫值篩選「{column}」",
columnFormatter: "欄位格式設定",
columnFormatterActive: "此欄位已套用格式化顯示",
columnFormatterFor: "格式化「{column}」",
columnFormatterHint: "僅改變顯示效果,原始值保持不變。",
formatterType: "類型",
@@ -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", () => {
@@ -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<string>();
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<string, any>;
@@ -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"]);
});
@@ -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"]);
});
@@ -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 () => {
+13 -4
View File
@@ -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,
};
}
+4
View File
@@ -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 {