mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(grid): display referenced values for foreign key columns
This commit is contained in:
@@ -199,6 +199,17 @@ import {
|
||||
type DataGridColumnSortState,
|
||||
} from "@/lib/dataGrid/dataGridContextMenu";
|
||||
import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssociationCells, foreignKeyMetadataRequestCurrent, foreignKeyNavigationTarget, foreignKeySourceColumnName, foreignKeyTableIdentity, type ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
import {
|
||||
collectForeignKeyDisplayValues,
|
||||
createForeignKeyDisplayRequestCoordinator,
|
||||
foreignKeyDisplayConfigMatches,
|
||||
foreignKeyDisplayLookupRequestKey,
|
||||
foreignKeyDisplayMapFromResult,
|
||||
formatForeignKeyDisplayValue,
|
||||
singleColumnForeignKey,
|
||||
splitForeignKeyDisplayValues,
|
||||
type ForeignKeyDisplayConfig,
|
||||
} from "@/lib/dataGrid/dataGridForeignKeyDisplay";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -871,6 +882,16 @@ const formatterMaskSuffix = ref(4);
|
||||
const formatterCustomId = ref(CUSTOM_FORMATTER_NEW);
|
||||
const formatterCustomName = ref("");
|
||||
const formatterCustomTemplate = ref("${value}");
|
||||
const formatterForeignKeyRefSchema = ref("");
|
||||
const formatterForeignKeyRefTable = ref("");
|
||||
const formatterForeignKeyRefColumn = ref("");
|
||||
const formatterForeignKeyDisplayColumn = ref("");
|
||||
const formatterForeignKeyColumns = ref<ColumnInfo[]>([]);
|
||||
const formatterForeignKeyColumnsLoading = ref(false);
|
||||
const formatterForeignKeyColumnsError = ref("");
|
||||
let formatterForeignKeyColumnsRequest = 0;
|
||||
const foreignKeyDisplayLabels = shallowRef(new Map<number, Map<string, string>>());
|
||||
const foreignKeyDisplayRequests = createForeignKeyDisplayRequestCoordinator();
|
||||
|
||||
const savedCustomFormatters = computed(() => {
|
||||
return Object.values(settingsStore.editorSettings.customColumnFormatters).sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1151,15 +1172,17 @@ function openCompactLocalFilter(colIdx: number, mode: LocalFilterMode = "local")
|
||||
});
|
||||
}
|
||||
|
||||
function compactColumnActionMenuItems(columnName: string) {
|
||||
function compactColumnActionMenuItems(columnName: string, columnIndex: number) {
|
||||
return createDataGridCompactColumnActionItems({
|
||||
labels: {
|
||||
formatter: t("grid.columnFormatter"),
|
||||
clearFormatter: t("grid.clearFormatter"),
|
||||
localFilter: t("grid.localFilter"),
|
||||
serverFilter: t("grid.databaseValueFilter"),
|
||||
},
|
||||
icons: { formatter: Code2, filter: Filter, database: Database },
|
||||
icons: { formatter: Code2, clearFormatter: Eraser, filter: Filter, database: Database },
|
||||
formatterAvailable: !!formatterKeyForColumn(columnName),
|
||||
formatterActive: columnHasFormatter(columnIndex),
|
||||
serverFilterAvailable: canUseServerColumnFilter.value,
|
||||
});
|
||||
}
|
||||
@@ -1171,6 +1194,8 @@ function columnFilterPanelTitle(columnName: string): string {
|
||||
function selectCompactColumnAction(value: string, columnIndex: number) {
|
||||
if (value === "formatter") {
|
||||
openCompactColumnFormatter(columnIndex);
|
||||
} else if (value === "clearFormatter") {
|
||||
clearColumnFormatter(columnIndex);
|
||||
} else if (value === "localFilter") {
|
||||
openCompactLocalFilter(columnIndex, "local");
|
||||
} else if (value === "serverFilter") {
|
||||
@@ -1242,6 +1267,15 @@ function currentFormatterDraft(): ColumnFormatterConfig {
|
||||
template: formatterCustomTemplate.value.trim() || "${value}",
|
||||
};
|
||||
}
|
||||
if (formatterKind.value === "foreign-key-display") {
|
||||
return {
|
||||
kind: "foreign-key-display",
|
||||
refSchema: formatterForeignKeyRefSchema.value || undefined,
|
||||
refTable: formatterForeignKeyRefTable.value,
|
||||
refColumn: formatterForeignKeyRefColumn.value,
|
||||
displayColumn: formatterForeignKeyDisplayColumn.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "datetime",
|
||||
unit: formatterDateUnit.value,
|
||||
@@ -1276,12 +1310,51 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
|
||||
formatterCustomId.value = CUSTOM_FORMATTER_NEW;
|
||||
formatterCustomName.value = "";
|
||||
formatterCustomTemplate.value = draft.template;
|
||||
} else if (draft.kind === "foreign-key-display") {
|
||||
formatterForeignKeyRefSchema.value = draft.refSchema ?? "";
|
||||
formatterForeignKeyRefTable.value = draft.refTable;
|
||||
formatterForeignKeyRefColumn.value = draft.refColumn;
|
||||
formatterForeignKeyDisplayColumn.value = draft.displayColumn;
|
||||
}
|
||||
}
|
||||
|
||||
function openColumnFormatter(columnIndex: number) {
|
||||
function formatterForeignKeyForColumn(columnIndex: number): ForeignKeyInfo | undefined {
|
||||
return singleColumnForeignKey(cellForeignKeyAssociation(columnIndex));
|
||||
}
|
||||
|
||||
async function loadFormatterForeignKeyColumns(columnIndex: number) {
|
||||
const request = ++formatterForeignKeyColumnsRequest;
|
||||
formatterForeignKeyColumns.value = [];
|
||||
formatterForeignKeyColumnsError.value = "";
|
||||
const foreignKey = formatterForeignKeyForColumn(columnIndex);
|
||||
if (!foreignKey || !props.connectionId) return;
|
||||
formatterForeignKeyColumnsLoading.value = true;
|
||||
try {
|
||||
const schema = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || props.database || "";
|
||||
const columns = await api.getColumns(props.connectionId, props.database || "", schema, foreignKey.ref_table, props.tableMeta?.catalog);
|
||||
if (request !== formatterForeignKeyColumnsRequest || formatterOpenColumn.value !== columnIndex) return;
|
||||
formatterForeignKeyColumns.value = columns;
|
||||
if (!formatterForeignKeyDisplayColumn.value || !columns.some((column) => column.name === formatterForeignKeyDisplayColumn.value)) {
|
||||
formatterForeignKeyDisplayColumn.value = columns.find((column) => column.name.toLowerCase() !== foreignKey.ref_column.toLowerCase())?.name ?? columns[0]?.name ?? "";
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (request === formatterForeignKeyColumnsRequest) formatterForeignKeyColumnsError.value = String(error?.message || error);
|
||||
} finally {
|
||||
if (request === formatterForeignKeyColumnsRequest) formatterForeignKeyColumnsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openColumnFormatter(columnIndex: number) {
|
||||
loadFormatterDraft(savedColumnFormatter(columnIndex));
|
||||
formatterOpenColumn.value = columnIndex;
|
||||
await fetchForeignKeys();
|
||||
const foreignKey = formatterForeignKeyForColumn(columnIndex);
|
||||
if (foreignKey) {
|
||||
formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || "";
|
||||
formatterForeignKeyRefTable.value = foreignKey.ref_table;
|
||||
formatterForeignKeyRefColumn.value = foreignKey.ref_column;
|
||||
await loadFormatterForeignKeyColumns(columnIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function openCompactColumnFormatter(columnIndex: number) {
|
||||
@@ -1290,12 +1363,13 @@ function openCompactColumnFormatter(columnIndex: number) {
|
||||
nextTick(() => {
|
||||
window.setTimeout(() => {
|
||||
guardHeaderPanelDismiss();
|
||||
openColumnFormatter(columnIndex);
|
||||
void openColumnFormatter(columnIndex);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function closeColumnFormatter() {
|
||||
formatterForeignKeyColumnsRequest += 1;
|
||||
formatterOpenColumn.value = null;
|
||||
}
|
||||
|
||||
@@ -1337,6 +1411,17 @@ function formatterDraftIsSavable(): boolean {
|
||||
return !!normalizeColumnFormatter(currentFormatterDraft());
|
||||
}
|
||||
|
||||
function selectFormatterKind(value: FormatterDraftKind, columnIndex: number) {
|
||||
formatterKind.value = value;
|
||||
if (value !== "foreign-key-display") return;
|
||||
const foreignKey = formatterForeignKeyForColumn(columnIndex);
|
||||
if (!foreignKey) return;
|
||||
formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || "";
|
||||
formatterForeignKeyRefTable.value = foreignKey.ref_table;
|
||||
formatterForeignKeyRefColumn.value = foreignKey.ref_column;
|
||||
void loadFormatterForeignKeyColumns(columnIndex);
|
||||
}
|
||||
|
||||
function selectCustomFormatter(value: string) {
|
||||
formatterCustomId.value = value;
|
||||
if (value === CUSTOM_FORMATTER_NEW) {
|
||||
@@ -1362,7 +1447,7 @@ function formatterPreviewRows(columnIndex: number) {
|
||||
return {
|
||||
index: index + 1,
|
||||
raw: displayCellValue(value),
|
||||
formatted: applyColumnFormatter(value, formatter),
|
||||
formatted: formatter?.kind === "foreign-key-display" ? formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)) : applyColumnFormatter(value, formatter),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -5467,6 +5552,9 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
|
||||
|
||||
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number): string {
|
||||
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
|
||||
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
|
||||
return limitDataGridCellDisplay(formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)), resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined);
|
||||
}
|
||||
const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex);
|
||||
const displayColumnInfo = columnInfo ?? (columnIndex === undefined ? undefined : resultColumnInfoForGridColumn(columnIndex));
|
||||
const arrayDisplay = formatter
|
||||
@@ -6428,6 +6516,7 @@ onMounted(() => {
|
||||
onDeactivated(pauseCanvasGridWork);
|
||||
onUnmounted(() => {
|
||||
dataGridRuntimeScope.dispose();
|
||||
foreignKeyDisplayRequests.dispose();
|
||||
clearCellFormatCache();
|
||||
pauseCanvasGridWork();
|
||||
canvasRuntime.dispose();
|
||||
@@ -9290,6 +9379,104 @@ function cellForeignKeyAssociation(actualColIdx: number): ForeignKeyAssociation
|
||||
return columnForeignKeyMap.value.get(columnName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function savedForeignKeyDisplayConfig(columnIndex: number): ForeignKeyDisplayConfig | undefined {
|
||||
const formatter = savedColumnFormatter(columnIndex);
|
||||
if (formatter?.kind !== "foreign-key-display") return undefined;
|
||||
const foreignKey = formatterForeignKeyForColumn(columnIndex);
|
||||
return foreignKey && foreignKeyDisplayConfigMatches(formatter, foreignKey, props.tableMeta?.schema || props.schema) ? formatter : undefined;
|
||||
}
|
||||
|
||||
async function loadForeignKeyDisplayLabels() {
|
||||
const requestGeneration = foreignKeyDisplayRequests.beginGeneration();
|
||||
foreignKeyDisplayLabels.value = new Map();
|
||||
clearCellFormatCache();
|
||||
scheduleCanvasDraw();
|
||||
if (!props.connectionId || !props.tableMeta || !foreignKeysLoaded.value) return;
|
||||
|
||||
const configuredColumns = props.result.columns.flatMap((_, columnIndex) => (savedForeignKeyDisplayConfig(columnIndex) ? [columnIndex] : []));
|
||||
await Promise.all(
|
||||
configuredColumns.map(async (columnIndex) => {
|
||||
const config = savedForeignKeyDisplayConfig(columnIndex);
|
||||
if (!config) return;
|
||||
const values = collectForeignKeyDisplayValues(props.result.rows, columnIndex);
|
||||
if (!values.length) return;
|
||||
const schema = config.refSchema || props.tableMeta?.schema || props.schema || props.database || "";
|
||||
try {
|
||||
const columns = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["columns", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () =>
|
||||
api.getColumns(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog),
|
||||
);
|
||||
if (!columns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
const refColumnInfo = columns.find((column) => column.name.toLowerCase() === config.refColumn.toLowerCase());
|
||||
const labels = new Map<string, string>();
|
||||
for (const batch of splitForeignKeyDisplayValues(values)) {
|
||||
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
const whereInput = await buildColumnValuesFilterCondition({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
columnName: config.refColumn,
|
||||
columnInfo: refColumnInfo,
|
||||
values: batch,
|
||||
});
|
||||
if (!whereInput) continue;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
driverProfile: connectionStore.getConfig(props.connectionId!)?.driver_profile,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
catalog: props.tableMeta?.catalog,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: config.refTable,
|
||||
columns: [config.refColumn, config.displayColumn],
|
||||
whereInput,
|
||||
limit: batch.length,
|
||||
});
|
||||
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
const requestKey = foreignKeyDisplayLookupRequestKey({
|
||||
connectionId: props.connectionId!,
|
||||
database: props.executionDatabase ?? props.database ?? "",
|
||||
catalog: props.tableMeta?.catalog,
|
||||
schema,
|
||||
table: config.refTable,
|
||||
refColumn: config.refColumn,
|
||||
displayColumn: config.displayColumn,
|
||||
values: batch,
|
||||
});
|
||||
const result = await foreignKeyDisplayRequests.request(requestGeneration, requestKey, () =>
|
||||
api.executeQuery(props.connectionId!, props.executionDatabase ?? props.database ?? "", sql, schema, undefined, {
|
||||
maxRows: batch.length,
|
||||
fetchSize: batch.length,
|
||||
pageSize: batch.length,
|
||||
}),
|
||||
);
|
||||
if (!result || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
for (const [key, label] of foreignKeyDisplayMapFromResult(result, config.refColumn, config.displayColumn)) if (!labels.has(key)) labels.set(key, label);
|
||||
}
|
||||
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
const next = new Map(foreignKeyDisplayLabels.value);
|
||||
next.set(columnIndex, labels);
|
||||
foreignKeyDisplayLabels.value = next;
|
||||
} catch (error: any) {
|
||||
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
appendDebugLog("warn", "[DBX][DataGrid:foreign-key-display:error]", {
|
||||
column: props.result.columns[columnIndex],
|
||||
message: String(error?.message || error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
|
||||
clearCellFormatCache();
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.result, currentForeignKeyTableIdentity.value, foreignKeysLoaded.value, JSON.stringify(settingsStore.editorSettings.columnFormatters)] as const,
|
||||
() => {
|
||||
void loadForeignKeyDisplayLabels();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function canvasCellForeignKey(rowIndex: number, actualColIdx: number): ForeignKeyInfo | null {
|
||||
const association = cellForeignKeyAssociation(actualColIdx);
|
||||
if (!association) return null;
|
||||
@@ -10710,8 +10897,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</LightDropdownMenu>
|
||||
<LightDropdownMenu
|
||||
v-if="compactColumnHeaderActions"
|
||||
:items="compactColumnActionMenuItems(col.name)"
|
||||
:items="compactColumnActionMenuItems(col.name, col.actualColIdx)"
|
||||
:open="headerActionMenuOpenColumn === col.actualColIdx"
|
||||
check-position="none"
|
||||
align="end"
|
||||
content-class="w-max min-w-28 max-w-48 p-0.5"
|
||||
item-class="gap-1 px-1.5 py-0.5 text-xs"
|
||||
@@ -10767,7 +10955,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
{{ t("grid.formatterType") }}
|
||||
</div>
|
||||
<Select :model-value="formatterKind" @update:model-value="(value: any) => (formatterKind = value)">
|
||||
<Select :model-value="formatterKind" @update:model-value="(value: any) => selectFormatterKind(value, col.actualColIdx)">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -10775,6 +10963,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<SelectItem value="datetime">{{ t("grid.formatterDatetime") }}</SelectItem>
|
||||
<SelectItem value="json-path">{{ t("grid.formatterJsonPath") }}</SelectItem>
|
||||
<SelectItem value="mask">{{ t("grid.formatterMask") }}</SelectItem>
|
||||
<SelectItem v-if="formatterForeignKeyForColumn(col.actualColIdx)" value="foreign-key-display">{{ t("grid.formatterForeignKeyDisplay") }}</SelectItem>
|
||||
<SelectItem value="custom-template">{{ t("grid.formatterCustomTemplate") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -10854,6 +11043,31 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="formatterKind === 'foreign-key-display'" class="space-y-3">
|
||||
<div class="rounded border bg-muted/30 px-2.5 py-2 text-xs">
|
||||
<div class="text-[11px] text-muted-foreground">{{ t("grid.formatterForeignKeyTarget") }}</div>
|
||||
<div class="mt-0.5 truncate font-mono" :title="`${formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : ''}${formatterForeignKeyRefTable}.${formatterForeignKeyRefColumn}`">
|
||||
{{ formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : "" }}{{ formatterForeignKeyRefTable }}.{{ formatterForeignKeyRefColumn }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeyDisplayColumn") }}</div>
|
||||
<SearchableSelect
|
||||
:model-value="formatterForeignKeyDisplayColumn"
|
||||
:options="formatterForeignKeyColumns.map((column) => column.name)"
|
||||
:placeholder="formatterForeignKeyColumnsLoading ? t('common.loading') : t('grid.formatterForeignKeyDisplayColumnPlaceholder')"
|
||||
:search-placeholder="t('grid.searchColumn')"
|
||||
:empty-text="formatterForeignKeyColumnsError || t('grid.noColumnsFound')"
|
||||
:loading-text="t('common.loading')"
|
||||
:loading="formatterForeignKeyColumnsLoading"
|
||||
:trigger-class="['border border-input h-8 pl-2.5 text-xs']"
|
||||
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
|
||||
@update:model-value="(value: any) => (formatterForeignKeyDisplayColumn = value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.formatterForeignKeyHint") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="savedCustomFormatters.length" class="space-y-1.5">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -1675,6 +1675,11 @@ export default {
|
||||
formatterDatetime: "Unix timestamp",
|
||||
formatterJsonPath: "JSON path",
|
||||
formatterMask: "Mask text",
|
||||
formatterForeignKeyDisplay: "Foreign key label",
|
||||
formatterForeignKeyTarget: "Referenced key",
|
||||
formatterForeignKeyDisplayColumn: "Display column",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "Select a display column",
|
||||
formatterForeignKeyHint: "Loads labels for keys on the current page. Editing, filtering, sorting, copying, and export continue to use raw values.",
|
||||
formatterCustomTemplate: "Custom template",
|
||||
formatterTimestampUnit: "Timestamp unit",
|
||||
formatterUnitAuto: "Auto",
|
||||
|
||||
@@ -1558,6 +1558,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Marca de tiempo Unix",
|
||||
formatterJsonPath: "Ruta JSON",
|
||||
formatterMask: "Enmascarar texto",
|
||||
formatterForeignKeyDisplay: "Etiqueta de clave externa",
|
||||
formatterForeignKeyTarget: "Clave referenciada",
|
||||
formatterForeignKeyDisplayColumn: "Columna de visualización",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "Seleccionar una columna",
|
||||
formatterForeignKeyHint: "Carga etiquetas para las claves de la página actual. La edición, el filtrado, la ordenación, la copia y la exportación usan los valores originales.",
|
||||
formatterCustomTemplate: "Plantilla personalizada",
|
||||
formatterTimestampUnit: "Unidad de marca de tiempo",
|
||||
formatterUnitAuto: "Automático",
|
||||
|
||||
@@ -1556,6 +1556,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Timestamp Unix",
|
||||
formatterJsonPath: "Percorso JSON",
|
||||
formatterMask: "Maschera testo",
|
||||
formatterForeignKeyDisplay: "Etichetta chiave esterna",
|
||||
formatterForeignKeyTarget: "Chiave referenziata",
|
||||
formatterForeignKeyDisplayColumn: "Colonna visualizzata",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "Seleziona una colonna",
|
||||
formatterForeignKeyHint: "Carica le etichette per le chiavi della pagina corrente. Modifica, filtro, ordinamento, copia ed esportazione usano i valori originali.",
|
||||
formatterCustomTemplate: "Modello personalizzato",
|
||||
formatterTimestampUnit: "Unità timestamp",
|
||||
formatterUnitAuto: "Auto",
|
||||
|
||||
@@ -1574,6 +1574,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Unixタイムスタンプ",
|
||||
formatterJsonPath: "JSONパス",
|
||||
formatterMask: "テキストをマスク",
|
||||
formatterForeignKeyDisplay: "外部キーラベル",
|
||||
formatterForeignKeyTarget: "参照キー",
|
||||
formatterForeignKeyDisplayColumn: "表示列",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択",
|
||||
formatterForeignKeyHint: "現在のページのキーに対応するラベルのみ読み込みます。編集、フィルター、並べ替え、コピー、エクスポートでは元の値を使用します。",
|
||||
formatterCustomTemplate: "カスタムテンプレート",
|
||||
formatterTimestampUnit: "タイムスタンプ単位",
|
||||
formatterUnitAuto: "自動",
|
||||
|
||||
@@ -1558,6 +1558,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Timestamp Unix",
|
||||
formatterJsonPath: "Caminho JSON",
|
||||
formatterMask: "Mascarar texto",
|
||||
formatterForeignKeyDisplay: "Rótulo de chave estrangeira",
|
||||
formatterForeignKeyTarget: "Chave referenciada",
|
||||
formatterForeignKeyDisplayColumn: "Coluna de exibição",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "Selecione uma coluna",
|
||||
formatterForeignKeyHint: "Carrega rótulos para as chaves da página atual. Edição, filtro, ordenação, cópia e exportação continuam usando os valores originais.",
|
||||
formatterCustomTemplate: "Template personalizado",
|
||||
formatterTimestampUnit: "Unidade do timestamp",
|
||||
formatterUnitAuto: "Automático",
|
||||
|
||||
@@ -1600,6 +1600,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Unix 时间戳",
|
||||
formatterJsonPath: "JSON 路径",
|
||||
formatterMask: "文本脱敏",
|
||||
formatterForeignKeyDisplay: "外键显示值",
|
||||
formatterForeignKeyTarget: "引用键",
|
||||
formatterForeignKeyDisplayColumn: "显示字段",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段",
|
||||
formatterForeignKeyHint: "仅加载当前页外键对应的显示值;编辑、筛选、排序、复制和导出仍使用原始值。",
|
||||
formatterCustomTemplate: "自定义模板",
|
||||
formatterTimestampUnit: "时间戳单位",
|
||||
formatterUnitAuto: "自动",
|
||||
|
||||
@@ -1557,6 +1557,11 @@ export default withEnglishFallback({
|
||||
formatterDatetime: "Unix 時間戳",
|
||||
formatterJsonPath: "JSON 路徑",
|
||||
formatterMask: "文字遮罩",
|
||||
formatterForeignKeyDisplay: "外鍵顯示值",
|
||||
formatterForeignKeyTarget: "引用鍵",
|
||||
formatterForeignKeyDisplayColumn: "顯示欄位",
|
||||
formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位",
|
||||
formatterForeignKeyHint: "僅載入目前頁面外鍵對應的顯示值;編輯、篩選、排序、複製與匯出仍使用原始值。",
|
||||
formatterCustomTemplate: "自訂範本",
|
||||
formatterTimestampUnit: "時間戳單位",
|
||||
formatterUnitAuto: "自動",
|
||||
|
||||
@@ -20,13 +20,28 @@ describe("dataGridContextMenu", () => {
|
||||
|
||||
it("omits unavailable server actions and disables unavailable formatter", () => {
|
||||
const items = createDataGridCompactColumnActionItems({
|
||||
labels: { formatter: "formatter", localFilter: "local", serverFilter: "server" },
|
||||
icons: { formatter: icon, filter: icon, database: icon },
|
||||
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
|
||||
icons: { formatter: icon, clearFormatter: icon, filter: icon, database: icon },
|
||||
formatterAvailable: false,
|
||||
formatterActive: false,
|
||||
serverFilterAvailable: false,
|
||||
});
|
||||
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter"]);
|
||||
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter", "clearFormatter"]);
|
||||
expect(items[0]?.disabled).toBe(true);
|
||||
expect(items[0]?.checked).toBe(false);
|
||||
expect(items.at(-1)).toMatchObject({ disabled: true, separatorBefore: true });
|
||||
});
|
||||
|
||||
it("marks an active formatter and enables the compact clear action", () => {
|
||||
const items = createDataGridCompactColumnActionItems({
|
||||
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
|
||||
icons: { formatter: icon, clearFormatter: icon, filter: icon, database: icon },
|
||||
formatterAvailable: true,
|
||||
formatterActive: true,
|
||||
serverFilterAvailable: true,
|
||||
});
|
||||
expect(items[0]).toMatchObject({ value: "formatter", checked: true, disabled: false });
|
||||
expect(items.at(-1)).toMatchObject({ value: "clearFormatter", disabled: false, separatorBefore: true });
|
||||
});
|
||||
|
||||
it("builds typed column, cell, and row capability groups", () => {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectForeignKeyDisplayValues,
|
||||
createForeignKeyDisplayRequestCoordinator,
|
||||
foreignKeyDisplayConfigMatches,
|
||||
foreignKeyDisplayLookupRequestKey,
|
||||
foreignKeyDisplayMapFromResult,
|
||||
formatForeignKeyDisplayValue,
|
||||
singleColumnForeignKey,
|
||||
splitForeignKeyDisplayValues,
|
||||
} from "@/lib/dataGrid/dataGridForeignKeyDisplay";
|
||||
import { buildColumnForeignKeyMap } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("dataGridForeignKeyDisplay", () => {
|
||||
it("only enables dictionary display for a single-column foreign key", () => {
|
||||
const single = buildColumnForeignKeyMap([{ name: "fk_user", column: "user_id", ref_schema: "public", ref_table: "users", ref_column: "id" }]).get("user_id");
|
||||
const composite = buildColumnForeignKeyMap([
|
||||
{ name: "fk_item", column: "order_id", ref_table: "items", ref_column: "order_id" },
|
||||
{ name: "fk_item", column: "line_no", ref_table: "items", ref_column: "line_no" },
|
||||
]).get("order_id");
|
||||
|
||||
expect(singleColumnForeignKey(single)?.ref_table).toBe("users");
|
||||
expect(singleColumnForeignKey(composite)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches a saved configuration against current foreign-key metadata", () => {
|
||||
const config = { kind: "foreign-key-display" as const, refSchema: "public", refTable: "users", refColumn: "id", displayColumn: "name" };
|
||||
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "PUBLIC", ref_table: "USERS", ref_column: "ID" })).toBe(true);
|
||||
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "public", ref_table: "accounts", ref_column: "id" })).toBe(false);
|
||||
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "archive", ref_table: "users", ref_column: "id" })).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates current-page keys with type-safe identities and bounded batches", () => {
|
||||
const rows = [[100], [100], ["100"], [null], [101], [{ id: 1 }]] as QueryResult["rows"];
|
||||
const values = collectForeignKeyDisplayValues(rows, 0);
|
||||
expect(values).toEqual([100, "100", 101]);
|
||||
expect(splitForeignKeyDisplayValues(values, 2)).toEqual([[100, "100"], [101]]);
|
||||
});
|
||||
|
||||
it("caps a large page at 2000 unique values and four 500-value batches", () => {
|
||||
const rows = Array.from({ length: 2500 }, (_, index) => [index]) as QueryResult["rows"];
|
||||
const values = collectForeignKeyDisplayValues(rows, 0);
|
||||
const batches = splitForeignKeyDisplayValues(values);
|
||||
|
||||
expect(values).toHaveLength(2000);
|
||||
expect(batches).toHaveLength(4);
|
||||
expect(batches.every((batch) => batch.length === 500)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds labels from query results and preserves raw values when no useful label exists", () => {
|
||||
const result = {
|
||||
columns: ["id", "name", "code"],
|
||||
rows: [
|
||||
[100, "张三", "U100"],
|
||||
[101, "李四", "U101"],
|
||||
[102, null, "U102"],
|
||||
[103, " ", "U103"],
|
||||
[100, "重复", "U100-duplicate"],
|
||||
],
|
||||
} as QueryResult;
|
||||
const labels = foreignKeyDisplayMapFromResult(result);
|
||||
const codeLabels = foreignKeyDisplayMapFromResult(result, "ID", "CODE");
|
||||
|
||||
expect(formatForeignKeyDisplayValue(100, labels)).toBe("100 (张三)");
|
||||
expect(formatForeignKeyDisplayValue(102, labels)).toBe("102");
|
||||
expect(formatForeignKeyDisplayValue(103, labels)).toBe("103");
|
||||
expect(formatForeignKeyDisplayValue(null, labels)).toBe("NULL");
|
||||
expect(formatForeignKeyDisplayValue("same", new Map([["string\u0000same", "same"]]))).toBe("same");
|
||||
expect(formatForeignKeyDisplayValue(100, codeLabels)).toBe("100 (U100)");
|
||||
expect(foreignKeyDisplayMapFromResult(result, "missing", "code")).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("deduplicates in-flight requests and reuses the bounded cache across generations", async () => {
|
||||
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
|
||||
const gate = deferred<number>();
|
||||
const generation = coordinator.beginGeneration();
|
||||
let calls = 0;
|
||||
const task = () => {
|
||||
calls += 1;
|
||||
return gate.promise;
|
||||
};
|
||||
const requestScope = { connectionId: "c1", database: "db", schema: "public", table: "users", refColumn: "id", displayColumn: "name" };
|
||||
const firstKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: [100, "100"] });
|
||||
const reorderedKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: ["100", 100] });
|
||||
expect(reorderedKey).toBe(firstKey);
|
||||
|
||||
const first = coordinator.request(generation, firstKey, task);
|
||||
const duplicate = coordinator.request(generation, reorderedKey, task);
|
||||
expect(calls).toBe(1);
|
||||
gate.resolve(7);
|
||||
await expect(Promise.all([first, duplicate])).resolves.toEqual([7, 7]);
|
||||
|
||||
const nextGeneration = coordinator.beginGeneration();
|
||||
await expect(coordinator.request(nextGeneration, firstKey, async () => 8)).resolves.toBe(7);
|
||||
expect(calls).toBe(1);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("enforces one concurrency limit across many configured-column batches", async () => {
|
||||
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
|
||||
const generation = coordinator.beginGeneration();
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let calls = 0;
|
||||
const requests = Array.from({ length: 12 }, (_, index) =>
|
||||
coordinator.request(generation, `column-batch-${index}`, async () => {
|
||||
calls += 1;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
return index;
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(Promise.all(requests)).resolves.toEqual(Array.from({ length: 12 }, (_, index) => index));
|
||||
expect(calls).toBe(12);
|
||||
expect(maxActive).toBe(2);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it("cancels stale queued work and prevents old-page results after rapid page switches", async () => {
|
||||
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
|
||||
const oldGate = deferred<void>();
|
||||
const oldGeneration = coordinator.beginGeneration();
|
||||
let oldStarted = 0;
|
||||
let newStarted = 0;
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const oldRequests = Array.from({ length: 8 }, (_, index) =>
|
||||
coordinator.request(oldGeneration, `old-${index}`, async () => {
|
||||
oldStarted += 1;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await oldGate.promise;
|
||||
active -= 1;
|
||||
return `old-${index}`;
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(oldStarted).toBe(2);
|
||||
|
||||
const newGeneration = coordinator.beginGeneration();
|
||||
const newRequests = Array.from({ length: 8 }, (_, index) =>
|
||||
coordinator.request(newGeneration, `new-${index}`, async () => {
|
||||
newStarted += 1;
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
return `new-${index}`;
|
||||
}),
|
||||
);
|
||||
oldGate.resolve();
|
||||
|
||||
const [oldResults, newResults] = await Promise.all([Promise.all(oldRequests), Promise.all(newRequests)]);
|
||||
expect(oldStarted).toBe(2);
|
||||
expect(newStarted).toBe(8);
|
||||
expect(maxActive).toBe(2);
|
||||
expect(oldResults).toEqual(Array(8).fill(undefined));
|
||||
expect(newResults).toEqual(Array.from({ length: 8 }, (_, index) => `new-${index}`));
|
||||
coordinator.dispose();
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,7 @@ export type ColumnFormatterConfig =
|
||||
| { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string; timezone: string | undefined }
|
||||
| { kind: "json-path"; path: string }
|
||||
| { kind: "mask"; prefix: number; suffix: number }
|
||||
| { kind: "foreign-key-display"; refSchema?: string; refTable: string; refColumn: string; displayColumn: string }
|
||||
| { kind: "custom-template"; template: string }
|
||||
| { kind: "custom-ref"; formatterId: string };
|
||||
|
||||
@@ -143,6 +144,19 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
|
||||
return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number };
|
||||
}
|
||||
|
||||
if (config.kind === "foreign-key-display") {
|
||||
if (typeof config.refTable !== "string" || !config.refTable.trim()) return undefined;
|
||||
if (typeof config.refColumn !== "string" || !config.refColumn.trim()) return undefined;
|
||||
if (typeof config.displayColumn !== "string" || !config.displayColumn.trim()) return undefined;
|
||||
return {
|
||||
kind: "foreign-key-display",
|
||||
refSchema: typeof config.refSchema === "string" && config.refSchema.trim() ? config.refSchema.trim() : undefined,
|
||||
refTable: config.refTable.trim(),
|
||||
refColumn: config.refColumn.trim(),
|
||||
displayColumn: config.displayColumn.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (config.kind === "custom-template") {
|
||||
return typeof config.template === "string" && config.template.trim() ? { kind: "custom-template", template: config.template.slice(0, 500) } : undefined;
|
||||
}
|
||||
|
||||
@@ -223,15 +223,17 @@ export function createDataGridSortMenuItems(options: { column: string; columnInd
|
||||
}
|
||||
|
||||
export function createDataGridCompactColumnActionItems(options: {
|
||||
labels: { formatter: string; localFilter: string; serverFilter: string };
|
||||
icons: { formatter: Component; filter: Component; database: Component };
|
||||
labels: { formatter: string; clearFormatter: string; localFilter: string; serverFilter: string };
|
||||
icons: { formatter: Component; clearFormatter: Component; filter: Component; database: Component };
|
||||
formatterAvailable: boolean;
|
||||
formatterActive: boolean;
|
||||
serverFilterAvailable: boolean;
|
||||
}): DataGridColumnMenuItem[] {
|
||||
const { labels, icons } = options;
|
||||
return [
|
||||
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable },
|
||||
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable, checked: options.formatterActive },
|
||||
{ label: labels.localFilter, value: "localFilter", icon: icons.filter },
|
||||
...(options.serverFilterAvailable ? [{ label: labels.serverFilter, value: "serverFilter", icon: icons.database }] : []),
|
||||
{ label: labels.clearFormatter, value: "clearFormatter", icon: icons.clearFormatter, disabled: !options.formatterActive, separatorBefore: true },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { ColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
|
||||
import type { ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
import type { ForeignKeyInfo, QueryResult } from "@/types/database";
|
||||
|
||||
export type ForeignKeyDisplayConfig = Extract<ColumnFormatterConfig, { kind: "foreign-key-display" }>;
|
||||
|
||||
export const FOREIGN_KEY_DISPLAY_BATCH_SIZE = 500;
|
||||
export const FOREIGN_KEY_DISPLAY_MAX_VALUES = 2000;
|
||||
export const FOREIGN_KEY_DISPLAY_REQUEST_CONCURRENCY = 2;
|
||||
export const FOREIGN_KEY_DISPLAY_REQUEST_CACHE_TTL_MS = 30_000;
|
||||
export const FOREIGN_KEY_DISPLAY_REQUEST_CACHE_MAX_ENTRIES = 128;
|
||||
|
||||
export interface ForeignKeyDisplayRequestCoordinatorOptions {
|
||||
concurrency?: number;
|
||||
cacheTtlMs?: number;
|
||||
maxCacheEntries?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface ForeignKeyDisplayRequestEntry {
|
||||
generation: number;
|
||||
state: "queued" | "running" | "resolved" | "cancelled";
|
||||
expiresAt: number;
|
||||
promise: Promise<unknown | undefined>;
|
||||
resolve: (value: unknown | undefined) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
task: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ForeignKeyDisplayRequestCoordinator {
|
||||
beginGeneration(): number;
|
||||
isCurrent(generation: number): boolean;
|
||||
request<T>(generation: number, key: string, task: () => Promise<T>): Promise<T | undefined>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createForeignKeyDisplayRequestCoordinator(options: ForeignKeyDisplayRequestCoordinatorOptions = {}): ForeignKeyDisplayRequestCoordinator {
|
||||
const concurrency = Math.max(1, Math.floor(options.concurrency ?? FOREIGN_KEY_DISPLAY_REQUEST_CONCURRENCY));
|
||||
const cacheTtlMs = Math.max(0, options.cacheTtlMs ?? FOREIGN_KEY_DISPLAY_REQUEST_CACHE_TTL_MS);
|
||||
const maxCacheEntries = Math.max(1, Math.floor(options.maxCacheEntries ?? FOREIGN_KEY_DISPLAY_REQUEST_CACHE_MAX_ENTRIES));
|
||||
const now = options.now ?? Date.now;
|
||||
const cache = new Map<string, ForeignKeyDisplayRequestEntry>();
|
||||
const queue: Array<{ key: string; entry: ForeignKeyDisplayRequestEntry }> = [];
|
||||
let generation = 0;
|
||||
let active = 0;
|
||||
let disposed = false;
|
||||
const isCurrent = (requestGeneration: number) => !disposed && requestGeneration === generation;
|
||||
|
||||
const removeEntry = (key: string, entry: ForeignKeyDisplayRequestEntry) => {
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
};
|
||||
const cancelQueuedEntry = (key: string, entry: ForeignKeyDisplayRequestEntry) => {
|
||||
if (entry.state !== "queued") return;
|
||||
entry.state = "cancelled";
|
||||
removeEntry(key, entry);
|
||||
entry.resolve(undefined);
|
||||
};
|
||||
const pruneCache = () => {
|
||||
const timestamp = now();
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.state === "resolved" && entry.expiresAt <= timestamp) cache.delete(key);
|
||||
}
|
||||
if (cache.size <= maxCacheEntries) return;
|
||||
for (const [key, entry] of cache) {
|
||||
if (cache.size <= maxCacheEntries) break;
|
||||
if (entry.state === "resolved") cache.delete(key);
|
||||
}
|
||||
};
|
||||
const drain = () => {
|
||||
while (!disposed && active < concurrency && queue.length) {
|
||||
const queued = queue.shift()!;
|
||||
const { key, entry } = queued;
|
||||
if (entry.state !== "queued") continue;
|
||||
if (entry.generation !== generation) {
|
||||
cancelQueuedEntry(key, entry);
|
||||
continue;
|
||||
}
|
||||
entry.state = "running";
|
||||
active += 1;
|
||||
void entry
|
||||
.task()
|
||||
.then((value) => {
|
||||
entry.state = "resolved";
|
||||
entry.expiresAt = now() + cacheTtlMs;
|
||||
entry.resolve(value);
|
||||
pruneCache();
|
||||
})
|
||||
.catch((error) => {
|
||||
removeEntry(key, entry);
|
||||
entry.reject(error);
|
||||
})
|
||||
.finally(() => {
|
||||
active -= 1;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
beginGeneration() {
|
||||
generation += 1;
|
||||
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||
const { key, entry } = queue[index];
|
||||
if (entry.generation === generation) continue;
|
||||
queue.splice(index, 1);
|
||||
cancelQueuedEntry(key, entry);
|
||||
}
|
||||
drain();
|
||||
return generation;
|
||||
},
|
||||
isCurrent,
|
||||
request<T>(requestGeneration: number, key: string, task: () => Promise<T>): Promise<T | undefined> {
|
||||
if (disposed || requestGeneration !== generation) return Promise.resolve(undefined);
|
||||
pruneCache();
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached.promise.then((value) => (isCurrent(requestGeneration) ? (value as T | undefined) : undefined));
|
||||
}
|
||||
let resolve!: (value: unknown | undefined) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<unknown | undefined>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const entry: ForeignKeyDisplayRequestEntry = {
|
||||
generation: requestGeneration,
|
||||
state: "queued",
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
task,
|
||||
};
|
||||
cache.set(key, entry);
|
||||
queue.push({ key, entry });
|
||||
drain();
|
||||
return promise.then((value) => (isCurrent(requestGeneration) ? (value as T | undefined) : undefined));
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
for (const { key, entry } of queue) cancelQueuedEntry(key, entry);
|
||||
queue.length = 0;
|
||||
cache.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function singleColumnForeignKey(association: ForeignKeyAssociation | null | undefined): ForeignKeyInfo | undefined {
|
||||
return association?.columnPairs.length === 1 ? association.foreignKey : undefined;
|
||||
}
|
||||
|
||||
export function foreignKeyDisplayConfigMatches(config: ForeignKeyDisplayConfig, foreignKey: ForeignKeyInfo, currentSchema?: string): boolean {
|
||||
const expectedSchema = config.refSchema || currentSchema || "";
|
||||
const actualSchema = foreignKey.ref_schema || currentSchema || "";
|
||||
return config.refTable.toLowerCase() === foreignKey.ref_table.toLowerCase() && config.refColumn.toLowerCase() === foreignKey.ref_column.toLowerCase() && expectedSchema.toLowerCase() === actualSchema.toLowerCase();
|
||||
}
|
||||
|
||||
export function foreignKeyDisplayValueKey(value: CellValue | undefined): string | undefined {
|
||||
if (value === null || value === undefined || typeof value === "object") return undefined;
|
||||
return `${typeof value}\u0000${String(value)}`;
|
||||
}
|
||||
|
||||
export function foreignKeyDisplayLookupRequestKey(options: { connectionId: string; database?: string; catalog?: string; schema?: string; table: string; refColumn: string; displayColumn: string; values: readonly CellValue[] }): string {
|
||||
const valueKeys = options.values.map((value) => foreignKeyDisplayValueKey(value) ?? "").sort();
|
||||
return JSON.stringify(["lookup", options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.table, options.refColumn, options.displayColumn, valueKeys]);
|
||||
}
|
||||
|
||||
export function collectForeignKeyDisplayValues(rows: QueryResult["rows"], columnIndex: number, maxValues = FOREIGN_KEY_DISPLAY_MAX_VALUES): CellValue[] {
|
||||
const values: CellValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const value = row[columnIndex] as CellValue | undefined;
|
||||
const key = foreignKeyDisplayValueKey(value);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
values.push(value!);
|
||||
if (values.length >= maxValues) break;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function splitForeignKeyDisplayValues(values: readonly CellValue[], batchSize = FOREIGN_KEY_DISPLAY_BATCH_SIZE): CellValue[][] {
|
||||
if (batchSize <= 0) return [];
|
||||
const batches: CellValue[][] = [];
|
||||
for (let index = 0; index < values.length; index += batchSize) batches.push(values.slice(index, index + batchSize));
|
||||
return batches;
|
||||
}
|
||||
|
||||
export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const keyIndex = result.columns.findIndex((column) => column.toLowerCase() === keyColumn?.toLowerCase());
|
||||
const displayIndex = result.columns.findIndex((column) => column.toLowerCase() === displayColumn?.toLowerCase());
|
||||
if (keyIndex < 0 || displayIndex < 0) return map;
|
||||
for (const row of result.rows) {
|
||||
const key = foreignKeyDisplayValueKey(row[keyIndex] as CellValue | undefined);
|
||||
const labelValue = row[displayIndex] as CellValue | undefined;
|
||||
if (!key || labelValue === null || labelValue === undefined || map.has(key)) continue;
|
||||
map.set(key, displayCellValue(labelValue));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap<string, string> | undefined): string {
|
||||
const raw = displayCellValue(value);
|
||||
const key = foreignKeyDisplayValueKey(value);
|
||||
const label = key ? labels?.get(key) : undefined;
|
||||
if (label === undefined || label === raw || !label.trim()) return raw;
|
||||
return `${raw} (${label})`;
|
||||
}
|
||||
+5
@@ -134,6 +134,11 @@ test("falls back to normal display for nulls and invalid formatter input", () =>
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "datetime", unit: "auto", pattern: 123 }), { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ss", timezone: undefined });
|
||||
});
|
||||
|
||||
test("normalizes foreign-key display formatter configuration", () => {
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "foreign-key-display", refSchema: " public ", refTable: " users ", refColumn: " id ", displayColumn: " name " }), { kind: "foreign-key-display", refSchema: "public", refTable: "users", refColumn: "id", displayColumn: "name" });
|
||||
assert.equal(normalizeColumnFormatter({ kind: "foreign-key-display", refTable: "users", refColumn: "id", displayColumn: "" }), undefined);
|
||||
});
|
||||
|
||||
test("normalizes only supported formatter configs", () => {
|
||||
const config: ColumnFormatterConfig = { kind: "mask", prefix: 2, suffix: 4 };
|
||||
|
||||
|
||||
+2
@@ -562,6 +562,7 @@ test("keeps only valid saved column formatter configs", () => {
|
||||
"conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" },
|
||||
"conn::db::public::users::invalid_json": { kind: "json-path", path: "user.name" },
|
||||
"conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },
|
||||
"conn::db::public::orders::user_id": { kind: "foreign-key-display", refSchema: "public", refTable: "users", refColumn: "id", displayColumn: "name" },
|
||||
},
|
||||
customColumnFormatters: {
|
||||
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
|
||||
@@ -575,6 +576,7 @@ test("keeps only valid saved column formatter configs", () => {
|
||||
"conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 },
|
||||
"conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" },
|
||||
"conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },
|
||||
"conn::db::public::orders::user_id": { kind: "foreign-key-display", refSchema: "public", refTable: "users", refColumn: "id", displayColumn: "name" },
|
||||
});
|
||||
assert.deepEqual(settings.customColumnFormatters, {
|
||||
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
|
||||
|
||||
Reference in New Issue
Block a user