feat(grid): inherit physical column formatting in query results
This commit is contained in:
19 files changed
+329
-53
No files matched your search
@@ -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')" />
|
||||
|
||||
+31
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1666,6 +1666,7 @@ export default withEnglishFallback({
|
||||
databaseValueFilter: "データベース値フィルター",
|
||||
databaseValueFilterFor: "'{column}'のデータベース値",
|
||||
columnFormatter: "列フォーマッター",
|
||||
columnFormatterActive: "この列には表示形式が適用されています",
|
||||
columnFormatterFor: "'{column}'のフォーマッター",
|
||||
columnFormatterHint: "表示のみをフォーマットし、実際の値は変更されません。",
|
||||
formatterType: "タイプ",
|
||||
|
||||
@@ -1649,6 +1649,7 @@ export default withEnglishFallback({
|
||||
databaseValueFilter: "데이터베이스 값 필터",
|
||||
databaseValueFilterFor: "'{column}' 데이터베이스 값",
|
||||
columnFormatter: "컬럼 포매터",
|
||||
columnFormatterActive: "이 열에 서식이 적용되어 있습니다",
|
||||
columnFormatterFor: "'{column}' 포매터",
|
||||
columnFormatterHint: "표시만 서식 지정하며 원시 값은 변경되지 않습니다.",
|
||||
formatterType: "유형",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1700,6 +1700,7 @@ export default withEnglishFallback({
|
||||
databaseValueFilter: "数据库值筛选",
|
||||
databaseValueFilterFor: "数据库值筛选“{column}”",
|
||||
columnFormatter: "列格式化",
|
||||
columnFormatterActive: "该列已应用格式化展示",
|
||||
columnFormatterFor: "格式化“{column}”",
|
||||
columnFormatterHint: "仅改变显示效果,原始值保持不变。",
|
||||
formatterType: "类型",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
+18
-14
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user