feat(data-grid): support manual reference display values

This commit is contained in:
zipg authored and GitHub committed 2026-08-23 01:38:50 +08:00
1 parent b74d49e982
commit 4a1f3e7141
24 files changed
+893 -90

No files matched your search

@@ -544,7 +544,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
// characters in the text (#6312 review). // characters in the text (#6312 review).
Map<String, IndexBuilder> byName = new LinkedHashMap<>(); Map<String, IndexBuilder> byName = new LinkedHashMap<>();
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " + String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " + "(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
"COALESCE(a.attname, " + profile.catalogPrefixedFunction("get_indexdef") + "(ix.indexrelid, k.n, true)) AS column_text, " + "COALESCE(a.attname, " + profile.catalogPrefixedFunction("get_indexdef") + "(ix.indexrelid, k.n, true)) AS column_text, " +
"(a.attname IS NULL) AS is_expression " + "(a.attname IS NULL) AS is_expression " +
"FROM " + profile.catalogRelation("index") + " ix " + "FROM " + profile.catalogRelation("index") + " ix " +
@@ -622,7 +622,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
return unchecked(() -> { return unchecked(() -> {
List<CatalogIndex> catalogIndexes = new ArrayList<>(); List<CatalogIndex> catalogIndexes = new ArrayList<>();
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " + String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " + "(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
"ix.indkey AS column_numbers " + "ix.indkey AS column_numbers " +
"FROM " + profile.catalogRelation("index") + " ix " + "FROM " + profile.catalogRelation("index") + " ix " +
"JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " + "JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " +
@@ -173,6 +173,7 @@ class PostgresLikeAgentTest {
String sql = String.join("\n", MetadataSqlFake.statements); String sql = String.join("\n", MetadataSqlFake.statements);
assertTrue(sql.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0"), sql); assertTrue(sql.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0"), sql);
assertTrue(sql.contains("pg_catalog.pg_get_indexdef(ix.indexrelid, k.n, true)"), sql); assertTrue(sql.contains("pg_catalog.pg_get_indexdef(ix.indexrelid, k.n, true)"), sql);
assertTrue(sql.contains("ix.indisunique AND ix.indisvalid"), sql);
} }
@Test @Test
+389 -51
View File
@@ -55,6 +55,7 @@ import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue"; import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import ErrorBanner from "@/components/ui/ErrorBanner.vue"; import ErrorBanner from "@/components/ui/ErrorBanner.vue";
@@ -243,13 +244,18 @@ import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssoci
import { import {
collectForeignKeyDisplayValues, collectForeignKeyDisplayValues,
createForeignKeyDisplayRequestCoordinator, createForeignKeyDisplayRequestCoordinator,
foreignKeyDisplayConfigMatches, foreignKeyDisplayConfigIsUsable,
foreignKeyDisplayLookupRequestKey, foreignKeyDisplayLookupRequestKey,
foreignKeyDisplayMapFromResult, foreignKeyDisplayMapFromResult,
formatForeignKeyDisplayValue, formatForeignKeyDisplayValue,
manualReferenceColumnValidation,
manualReferenceKeyColumnIsUnique,
manualReferenceKeyColumns,
reconcileManualReferenceColumn,
singleColumnForeignKey, singleColumnForeignKey,
splitForeignKeyDisplayValues, splitForeignKeyDisplayValues,
type ForeignKeyDisplayConfig, type ForeignKeyDisplayConfig,
type ManualReferenceMetadataStatus,
} from "@/lib/dataGrid/dataGridForeignKeyDisplay"; } from "@/lib/dataGrid/dataGridForeignKeyDisplay";
import { useToast } from "@/composables/useToast"; import { useToast } from "@/composables/useToast";
@@ -1092,13 +1098,40 @@ const formatterForeignKeyRefSchema = ref("");
const formatterForeignKeyRefTable = ref(""); const formatterForeignKeyRefTable = ref("");
const formatterForeignKeyRefColumn = ref(""); const formatterForeignKeyRefColumn = ref("");
const formatterForeignKeyDisplayColumn = ref(""); const formatterForeignKeyDisplayColumn = ref("");
const formatterForeignKeyManual = ref(false);
const formatterForeignKeySchemas = ref<string[]>([]);
const formatterForeignKeyTables = ref<string[]>([]);
const formatterForeignKeyColumns = ref<ColumnInfo[]>([]); const formatterForeignKeyColumns = ref<ColumnInfo[]>([]);
const formatterForeignKeyReferenceKeyColumns = ref<string[]>([]);
const formatterForeignKeyReferenceMetadataStatus = ref<ManualReferenceMetadataStatus>("loading");
const formatterForeignKeySchemasLoading = ref(false);
const formatterForeignKeyTablesLoading = ref(false);
const formatterForeignKeyColumnsLoading = ref(false); const formatterForeignKeyColumnsLoading = ref(false);
const formatterForeignKeyTargetError = ref("");
const formatterForeignKeyColumnsError = ref(""); const formatterForeignKeyColumnsError = ref("");
const formatterForeignKeyReferenceMetadataError = ref("");
const formatterForeignKeyFilterEnabled = ref(false);
const formatterForeignKeyFilterColumn = ref("");
const formatterForeignKeyFilterMode = ref<DataGridContextFilterMode>("equals");
const formatterForeignKeyFilterValue = ref("");
const formatterForeignKeyFilterEndValue = ref("");
let formatterForeignKeyColumnsRequest = 0; let formatterForeignKeyColumnsRequest = 0;
const foreignKeyDisplayLabels = shallowRef(new Map<number, Map<string, string>>()); let formatterForeignKeyTargetRequest = 0;
interface ForeignKeyDisplayLabelState {
keyDataType?: string;
labels: Map<string, string>;
}
const formatterForeignKeyReferenceColumns = computed(() => (formatterForeignKeyManual.value ? manualReferenceKeyColumns(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeyColumns.value) : formatterForeignKeyColumns.value));
const formatterForeignKeyReferenceValidation = computed(() => manualReferenceColumnValidation(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeyColumns.value, formatterForeignKeyRefColumn.value, formatterForeignKeyReferenceMetadataStatus.value));
const foreignKeyDisplayLabels = shallowRef(new Map<number, ForeignKeyDisplayLabelState>());
const foreignKeyDisplayRequests = createForeignKeyDisplayRequestCoordinator(); const foreignKeyDisplayRequests = createForeignKeyDisplayRequestCoordinator();
function formatForeignKeyCellDisplay(value: CellValue, columnIndex: number): string {
const state = foreignKeyDisplayLabels.value.get(columnIndex);
return formatForeignKeyDisplayValue(value, state?.labels, state?.keyDataType);
}
const savedCustomFormatters = computed(() => { const savedCustomFormatters = computed(() => {
return Object.values(settingsStore.editorSettings.customColumnFormatters).sort((a, b) => a.name.localeCompare(b.name)); return Object.values(settingsStore.editorSettings.customColumnFormatters).sort((a, b) => a.name.localeCompare(b.name));
}); });
@@ -1480,10 +1513,19 @@ function currentFormatterDraft(): ColumnFormatterConfig {
if (formatterKind.value === "foreign-key-display") { if (formatterKind.value === "foreign-key-display") {
return { return {
kind: "foreign-key-display", kind: "foreign-key-display",
referenceMode: formatterForeignKeyManual.value ? "manual" : "foreign-key",
refSchema: formatterForeignKeyRefSchema.value || undefined, refSchema: formatterForeignKeyRefSchema.value || undefined,
refTable: formatterForeignKeyRefTable.value, refTable: formatterForeignKeyRefTable.value,
refColumn: formatterForeignKeyRefColumn.value, refColumn: formatterForeignKeyRefColumn.value,
displayColumn: formatterForeignKeyDisplayColumn.value, displayColumn: formatterForeignKeyDisplayColumn.value,
filter: formatterForeignKeyFilterEnabled.value
? {
column: formatterForeignKeyFilterColumn.value,
mode: formatterForeignKeyFilterMode.value,
value: formatterForeignKeyFilterValue.value,
endValue: formatterForeignKeyFilterEndValue.value,
}
: undefined,
}; };
} }
return { return {
@@ -1495,6 +1537,24 @@ function currentFormatterDraft(): ColumnFormatterConfig {
} }
function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
formatterForeignKeyManual.value = false;
formatterForeignKeyRefSchema.value = "";
formatterForeignKeyRefTable.value = "";
formatterForeignKeyRefColumn.value = "";
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeySchemas.value = [];
formatterForeignKeyTables.value = [];
formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyTargetError.value = "";
formatterForeignKeyColumnsError.value = "";
formatterForeignKeyReferenceMetadataError.value = "";
formatterForeignKeyFilterEnabled.value = false;
formatterForeignKeyFilterColumn.value = "";
formatterForeignKeyFilterMode.value = "equals";
formatterForeignKeyFilterValue.value = "";
formatterForeignKeyFilterEndValue.value = "";
const draft = formatter ?? { const draft = formatter ?? {
kind: "datetime", kind: "datetime",
unit: "auto" as const, unit: "auto" as const,
@@ -1527,10 +1587,16 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
formatterCustomName.value = ""; formatterCustomName.value = "";
formatterCustomTemplate.value = draft.template; formatterCustomTemplate.value = draft.template;
} else if (draft.kind === "foreign-key-display") { } else if (draft.kind === "foreign-key-display") {
formatterForeignKeyManual.value = draft.referenceMode === "manual";
formatterForeignKeyRefSchema.value = draft.refSchema ?? ""; formatterForeignKeyRefSchema.value = draft.refSchema ?? "";
formatterForeignKeyRefTable.value = draft.refTable; formatterForeignKeyRefTable.value = draft.refTable;
formatterForeignKeyRefColumn.value = draft.refColumn; formatterForeignKeyRefColumn.value = draft.refColumn;
formatterForeignKeyDisplayColumn.value = draft.displayColumn; formatterForeignKeyDisplayColumn.value = draft.displayColumn;
formatterForeignKeyFilterEnabled.value = !!draft.filter;
formatterForeignKeyFilterColumn.value = draft.filter?.column ?? "";
formatterForeignKeyFilterMode.value = draft.filter?.mode ?? "equals";
formatterForeignKeyFilterValue.value = draft.filter?.value ?? "";
formatterForeignKeyFilterEndValue.value = draft.filter?.endValue ?? "";
} }
} }
@@ -1541,18 +1607,41 @@ function formatterForeignKeyForColumn(columnIndex: number): ForeignKeyInfo | und
async function loadFormatterForeignKeyColumns(columnIndex: number) { async function loadFormatterForeignKeyColumns(columnIndex: number) {
const request = ++formatterForeignKeyColumnsRequest; const request = ++formatterForeignKeyColumnsRequest;
formatterForeignKeyColumns.value = []; formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyColumnsError.value = ""; formatterForeignKeyColumnsError.value = "";
const foreignKey = formatterForeignKeyForColumn(columnIndex); formatterForeignKeyReferenceMetadataError.value = "";
if (!foreignKey || !props.connectionId) return; if (!props.connectionId || !formatterForeignKeyRefTable.value) return;
formatterForeignKeyColumnsLoading.value = true; formatterForeignKeyColumnsLoading.value = true;
try { try {
const schema = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || props.database || ""; const schema = formatterForeignKeyRefSchema.value || props.tableMeta?.schema || props.schema || props.database || "";
const columns = await api.getColumns(props.connectionId, props.database || "", schema, foreignKey.ref_table, props.tableMeta?.catalog); const manual = formatterForeignKeyManual.value;
const [columnsResult, referenceKeysResult] = await Promise.allSettled([
api.getColumns(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog),
manual ? api.listReferenceKeyColumns(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog) : Promise.resolve([] as string[]),
]);
if (request !== formatterForeignKeyColumnsRequest || formatterOpenColumn.value !== columnIndex) return; if (request !== formatterForeignKeyColumnsRequest || formatterOpenColumn.value !== columnIndex) return;
if (columnsResult.status === "rejected") throw columnsResult.reason;
const columns = columnsResult.value;
formatterForeignKeyColumns.value = columns; formatterForeignKeyColumns.value = columns;
if (!formatterForeignKeyDisplayColumn.value || !columns.some((column) => column.name === formatterForeignKeyDisplayColumn.value)) { if (manual && referenceKeysResult.status === "rejected") {
formatterForeignKeyDisplayColumn.value = columns.find((column) => column.name.toLowerCase() !== foreignKey.ref_column.toLowerCase())?.name ?? columns[0]?.name ?? ""; formatterForeignKeyReferenceMetadataStatus.value = "unavailable";
formatterForeignKeyReferenceMetadataError.value = String(referenceKeysResult.reason?.message || referenceKeysResult.reason);
} else {
formatterForeignKeyReferenceKeyColumns.value = referenceKeysResult.status === "fulfilled" ? referenceKeysResult.value : [];
formatterForeignKeyReferenceMetadataStatus.value = "available";
} }
const referenceColumns = manualReferenceKeyColumns(columns, formatterForeignKeyReferenceKeyColumns.value);
const foreignKey = formatterForeignKeyForColumn(columnIndex);
if (manual) {
formatterForeignKeyRefColumn.value = reconcileManualReferenceColumn(formatterForeignKeyRefColumn.value, referenceColumns, formatterForeignKeyReferenceMetadataStatus.value);
} else if (!formatterForeignKeyRefColumn.value || !columns.some((column) => column.name === formatterForeignKeyRefColumn.value)) {
formatterForeignKeyRefColumn.value = foreignKey?.ref_column ?? "";
}
if (!formatterForeignKeyDisplayColumn.value || !columns.some((column) => column.name === formatterForeignKeyDisplayColumn.value)) {
formatterForeignKeyDisplayColumn.value = columns.find((column) => column.name !== formatterForeignKeyRefColumn.value)?.name ?? columns[0]?.name ?? "";
}
if (formatterForeignKeyFilterEnabled.value && !columns.some((column) => column.name === formatterForeignKeyFilterColumn.value)) formatterForeignKeyFilterColumn.value = "";
} catch (error: any) { } catch (error: any) {
if (request === formatterForeignKeyColumnsRequest) formatterForeignKeyColumnsError.value = String(error?.message || error); if (request === formatterForeignKeyColumnsRequest) formatterForeignKeyColumnsError.value = String(error?.message || error);
} finally { } finally {
@@ -1560,12 +1649,94 @@ async function loadFormatterForeignKeyColumns(columnIndex: number) {
} }
} }
function formatterForeignKeyDefaultSchema() {
return formatterForeignKeyRefSchema.value || props.tableMeta?.schema || props.schema || props.database || "";
}
async function loadFormatterManualReferenceTables(columnIndex: number, request = ++formatterForeignKeyTargetRequest) {
formatterForeignKeyTables.value = [];
formatterForeignKeyTargetError.value = "";
if (!props.connectionId) return;
formatterForeignKeyTablesLoading.value = true;
try {
const tables = await api.listTables(props.connectionId, props.database || "", formatterForeignKeyRefSchema.value, undefined, undefined, undefined, undefined, props.tableMeta?.catalog);
if (request !== formatterForeignKeyTargetRequest || formatterOpenColumn.value !== columnIndex) return;
formatterForeignKeyTables.value = [...new Set(tables.map((table) => table.name))].sort((left, right) => left.localeCompare(right));
if (formatterForeignKeyRefTable.value && !formatterForeignKeyTables.value.includes(formatterForeignKeyRefTable.value)) formatterForeignKeyTables.value.unshift(formatterForeignKeyRefTable.value);
if (formatterForeignKeyRefTable.value) await loadFormatterForeignKeyColumns(columnIndex);
} catch (error: any) {
if (request === formatterForeignKeyTargetRequest) formatterForeignKeyTargetError.value = String(error?.message || error);
} finally {
if (request === formatterForeignKeyTargetRequest) formatterForeignKeyTablesLoading.value = false;
}
}
async function loadFormatterManualReferenceOptions(columnIndex: number) {
const request = ++formatterForeignKeyTargetRequest;
formatterForeignKeySchemas.value = [];
formatterForeignKeySchemasLoading.value = true;
formatterForeignKeyTargetError.value = "";
try {
const schemas = props.connectionId ? await api.listSchemas(props.connectionId, props.database || "") : [];
if (request !== formatterForeignKeyTargetRequest || formatterOpenColumn.value !== columnIndex) return;
const selectedSchema = formatterForeignKeyDefaultSchema();
formatterForeignKeyRefSchema.value = selectedSchema;
formatterForeignKeySchemas.value = [...new Set([selectedSchema, ...schemas].filter(Boolean))].sort((left, right) => left.localeCompare(right));
} catch (error: any) {
if (request !== formatterForeignKeyTargetRequest) return;
const selectedSchema = formatterForeignKeyDefaultSchema();
formatterForeignKeyRefSchema.value = selectedSchema;
formatterForeignKeySchemas.value = selectedSchema ? [selectedSchema] : [];
formatterForeignKeyTargetError.value = String(error?.message || error);
} finally {
if (request === formatterForeignKeyTargetRequest) formatterForeignKeySchemasLoading.value = false;
}
if (request === formatterForeignKeyTargetRequest) await loadFormatterManualReferenceTables(columnIndex, request);
}
function selectFormatterForeignKeySchema(value: string, columnIndex: number) {
formatterForeignKeyColumnsRequest += 1;
formatterForeignKeyRefSchema.value = value;
formatterForeignKeyRefTable.value = "";
formatterForeignKeyRefColumn.value = "";
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeyFilterColumn.value = "";
formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyReferenceMetadataError.value = "";
void loadFormatterManualReferenceTables(columnIndex);
}
function selectFormatterForeignKeyTable(value: string, columnIndex: number) {
formatterForeignKeyTargetRequest += 1;
formatterForeignKeyRefTable.value = value;
formatterForeignKeyRefColumn.value = "";
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeyFilterColumn.value = "";
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyReferenceMetadataError.value = "";
void loadFormatterForeignKeyColumns(columnIndex);
}
function selectFormatterForeignKeyFilterMode(value: DataGridContextFilterMode) {
formatterForeignKeyFilterMode.value = value;
if (!filterModeNeedsValue(value)) formatterForeignKeyFilterValue.value = formatterForeignKeyFilterEndValue.value = "";
else if (!filterModeUsesRange(value)) formatterForeignKeyFilterEndValue.value = "";
}
async function openColumnFormatter(columnIndex: number) { async function openColumnFormatter(columnIndex: number) {
loadFormatterDraft(savedColumnFormatter(columnIndex)); const savedFormatter = savedColumnFormatter(columnIndex);
loadFormatterDraft(savedFormatter);
formatterOpenColumn.value = columnIndex; formatterOpenColumn.value = columnIndex;
await fetchForeignKeys(); await fetchForeignKeys();
const foreignKey = formatterForeignKeyForColumn(columnIndex); const foreignKey = formatterForeignKeyForColumn(columnIndex);
if (foreignKey) { if (savedFormatter?.kind === "foreign-key-display" && savedFormatter.referenceMode === "manual") {
formatterForeignKeyManual.value = true;
await loadFormatterManualReferenceOptions(columnIndex);
} else if (foreignKey) {
formatterForeignKeyManual.value = false;
formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || ""; formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || "";
formatterForeignKeyRefTable.value = foreignKey.ref_table; formatterForeignKeyRefTable.value = foreignKey.ref_table;
formatterForeignKeyRefColumn.value = foreignKey.ref_column; formatterForeignKeyRefColumn.value = foreignKey.ref_column;
@@ -1586,6 +1757,7 @@ function openCompactColumnFormatter(columnIndex: number) {
function closeColumnFormatter() { function closeColumnFormatter() {
formatterForeignKeyColumnsRequest += 1; formatterForeignKeyColumnsRequest += 1;
formatterForeignKeyTargetRequest += 1;
formatterOpenColumn.value = null; formatterOpenColumn.value = null;
} }
@@ -1635,18 +1807,29 @@ function clearColumnFormatter(columnIndex: number) {
} }
function formatterDraftIsSavable(): boolean { function formatterDraftIsSavable(): boolean {
return !!normalizeColumnFormatter(currentFormatterDraft()); const formatter = normalizeColumnFormatter(currentFormatterDraft());
if (!formatter || formatter.kind !== "foreign-key-display" || formatter.referenceMode !== "manual") return !!formatter;
return formatterForeignKeyReferenceValidation.value === "valid";
} }
function selectFormatterKind(value: FormatterDraftKind, columnIndex: number) { function selectFormatterKind(value: FormatterDraftKind, columnIndex: number) {
formatterKind.value = value; formatterKind.value = value;
if (value !== "foreign-key-display") return; if (value !== "foreign-key-display") return;
const foreignKey = formatterForeignKeyForColumn(columnIndex); const foreignKey = formatterForeignKeyForColumn(columnIndex);
if (!foreignKey) return; formatterForeignKeyManual.value = !foreignKey;
formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || ""; if (foreignKey) {
formatterForeignKeyRefTable.value = foreignKey.ref_table; formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || "";
formatterForeignKeyRefColumn.value = foreignKey.ref_column; formatterForeignKeyRefTable.value = foreignKey.ref_table;
void loadFormatterForeignKeyColumns(columnIndex); formatterForeignKeyRefColumn.value = foreignKey.ref_column;
void loadFormatterForeignKeyColumns(columnIndex);
} else {
formatterForeignKeyRefSchema.value = formatterForeignKeyDefaultSchema();
formatterForeignKeyRefTable.value = "";
formatterForeignKeyRefColumn.value = "";
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeyFilterEnabled.value = false;
void loadFormatterManualReferenceOptions(columnIndex);
}
} }
function selectCustomFormatter(value: string) { function selectCustomFormatter(value: string) {
@@ -1702,7 +1885,7 @@ function formatterPreviewRows(columnIndex: number) {
return { return {
index: index + 1, index: index + 1,
raw: displayCellValue(value), raw: displayCellValue(value),
formatted: formatter?.kind === "foreign-key-display" ? formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)) : applyColumnFormatter(value, formatter), formatted: formatter?.kind === "foreign-key-display" ? formatForeignKeyCellDisplay(value, columnIndex) : applyColumnFormatter(value, formatter),
}; };
}); });
} }
@@ -6314,7 +6497,7 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string { function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string {
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) { if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
const display = formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)); const display = formatForeignKeyCellDisplay(value, columnIndex);
return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display; return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display;
} }
const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex); const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex);
@@ -10352,7 +10535,7 @@ function savedForeignKeyDisplayConfig(columnIndex: number): ForeignKeyDisplayCon
const formatter = savedColumnFormatter(columnIndex); const formatter = savedColumnFormatter(columnIndex);
if (formatter?.kind !== "foreign-key-display") return undefined; if (formatter?.kind !== "foreign-key-display") return undefined;
const foreignKey = formatterForeignKeyForColumn(columnIndex); const foreignKey = formatterForeignKeyForColumn(columnIndex);
return foreignKey && foreignKeyDisplayConfigMatches(formatter, foreignKey, props.tableMeta?.schema || props.schema) ? formatter : undefined; return foreignKeyDisplayConfigIsUsable(formatter, foreignKey, props.tableMeta?.schema || props.schema) ? formatter : undefined;
} }
async function loadForeignKeyDisplayLabels() { async function loadForeignKeyDisplayLabels() {
@@ -10360,22 +10543,37 @@ async function loadForeignKeyDisplayLabels() {
foreignKeyDisplayLabels.value = new Map(); foreignKeyDisplayLabels.value = new Map();
clearCellFormatCache(); clearCellFormatCache();
scheduleCanvasDraw(); scheduleCanvasDraw();
if (!props.connectionId || !props.tableMeta || !foreignKeysLoaded.value) return; if (!props.connectionId || !props.tableMeta) return;
const configuredColumns = props.result.columns.flatMap((_, columnIndex) => (savedForeignKeyDisplayConfig(columnIndex) ? [columnIndex] : [])); const configuredColumns = props.result.columns.flatMap((_, columnIndex) => (savedForeignKeyDisplayConfig(columnIndex) ? [columnIndex] : []));
await Promise.all( await Promise.all(
configuredColumns.map(async (columnIndex) => { configuredColumns.map(async (columnIndex) => {
const config = savedForeignKeyDisplayConfig(columnIndex); const config = savedForeignKeyDisplayConfig(columnIndex);
if (!config) return; 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 || ""; const schema = config.refSchema || props.tableMeta?.schema || props.schema || props.database || "";
try { try {
const columns = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["columns", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () => 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), api.getColumns(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog),
); );
if (!columns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; if (!columns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
const refColumnInfo = columns.find((column) => column.name.toLowerCase() === config.refColumn.toLowerCase()); const refColumnInfo = columns.find((column) => column.name === config.refColumn);
if (!refColumnInfo) return;
if (config.referenceMode === "manual") {
const referenceKeyColumns = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["reference-key-columns", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () =>
api.listReferenceKeyColumns(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog),
);
if (!referenceKeyColumns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
if (!manualReferenceKeyColumnIsUnique(columns, referenceKeyColumns, config.refColumn)) {
appendDebugLog("warn", "[DBX][DataGrid:foreign-key-display:non-unique-manual-reference]", {
column: props.result.columns[columnIndex],
reference: `${schema}.${config.refTable}.${config.refColumn}`,
});
return;
}
}
const keyDataType = refColumnInfo.data_type;
const values = collectForeignKeyDisplayValues(props.result.rows, columnIndex, keyDataType);
if (!values.length) return;
const labels = new Map<string, string>(); const labels = new Map<string, string>();
for (const batch of splitForeignKeyDisplayValues(values)) { for (const batch of splitForeignKeyDisplayValues(values)) {
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
@@ -10387,6 +10585,22 @@ async function loadForeignKeyDisplayLabels() {
values: batch, values: batch,
}); });
if (!whereInput) continue; if (!whereInput) continue;
let lookupWhereInput = whereInput;
if (config.filter) {
const filter = config.filter;
const filterColumnInfo = columns.find((column) => column.name === filter.column);
const filterCondition = await buildDataGridContextFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName: filter.column,
mode: filter.mode,
value: !filterModeNeedsValue(filter.mode) || filterModeUsesList(filter.mode) ? null : parseFilterValue(filter.value ?? "", filterColumnInfo, resolvedDatabaseType.value),
values: filterModeUsesList(filter.mode) ? parseFilterValues(filter.value ?? "", filterColumnInfo, resolvedDatabaseType.value) : undefined,
endValue: filterModeUsesRange(filter.mode) ? parseFilterValue(filter.endValue ?? "", filterColumnInfo, resolvedDatabaseType.value) : undefined,
columnInfo: filterColumnInfo,
});
lookupWhereInput = combineWhereInputs(whereInput, filterCondition) ?? whereInput;
}
const sql = await buildTableSelectSql({ const sql = await buildTableSelectSql({
databaseType: resolvedDatabaseType.value, databaseType: resolvedDatabaseType.value,
driverProfile: connectionStore.getConfig(props.connectionId!)?.driver_profile, driverProfile: connectionStore.getConfig(props.connectionId!)?.driver_profile,
@@ -10397,7 +10611,7 @@ async function loadForeignKeyDisplayLabels() {
tableName: config.refTable, tableName: config.refTable,
columns: [config.refColumn, config.displayColumn], columns: [config.refColumn, config.displayColumn],
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
whereInput, whereInput: lookupWhereInput,
limit: batch.length, limit: batch.length,
}); });
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
@@ -10409,6 +10623,8 @@ async function loadForeignKeyDisplayLabels() {
table: config.refTable, table: config.refTable,
refColumn: config.refColumn, refColumn: config.refColumn,
displayColumn: config.displayColumn, displayColumn: config.displayColumn,
keyDataType,
filter: config.filter,
values: batch, values: batch,
}); });
const result = await foreignKeyDisplayRequests.request(requestGeneration, requestKey, () => const result = await foreignKeyDisplayRequests.request(requestGeneration, requestKey, () =>
@@ -10419,11 +10635,11 @@ async function loadForeignKeyDisplayLabels() {
}), }),
); );
if (!result || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; 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); for (const [key, label] of foreignKeyDisplayMapFromResult(result, config.refColumn, config.displayColumn, keyDataType)) if (!labels.has(key)) labels.set(key, label);
} }
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
const next = new Map(foreignKeyDisplayLabels.value); const next = new Map(foreignKeyDisplayLabels.value);
next.set(columnIndex, labels); next.set(columnIndex, { keyDataType, labels });
foreignKeyDisplayLabels.value = next; foreignKeyDisplayLabels.value = next;
} catch (error: any) { } catch (error: any) {
if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
@@ -11914,8 +12130,15 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<Code2 class="h-3.5 w-3.5" /> <Code2 class="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" side="bottom" class="w-[450px] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop> <PopoverContent
<div class="border-b bg-muted/40 px-3 py-2"> align="start"
side="bottom"
:collision-padding="8"
class="flex max-h-[var(--reka-popover-content-available-height)] w-[520px] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl"
@click.stop
@keydown.stop
>
<div class="shrink-0 border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold"> <div class="text-sm font-semibold">
{{ {{
t("grid.columnFormatterFor", { t("grid.columnFormatterFor", {
@@ -11927,7 +12150,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
{{ t("grid.columnFormatterHint") }} {{ t("grid.columnFormatterHint") }}
</div> </div>
</div> </div>
<div class="space-y-3 p-3"> <div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground"> <div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterType") }} {{ t("grid.formatterType") }}
@@ -11940,7 +12163,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<SelectItem value="datetime">{{ t("grid.formatterDatetime") }}</SelectItem> <SelectItem value="datetime">{{ t("grid.formatterDatetime") }}</SelectItem>
<SelectItem value="json-path">{{ t("grid.formatterJsonPath") }}</SelectItem> <SelectItem value="json-path">{{ t("grid.formatterJsonPath") }}</SelectItem>
<SelectItem value="mask">{{ t("grid.formatterMask") }}</SelectItem> <SelectItem value="mask">{{ t("grid.formatterMask") }}</SelectItem>
<SelectItem v-if="formatterForeignKeyForColumn(col.actualColIdx)" value="foreign-key-display">{{ t("grid.formatterForeignKeyDisplay") }}</SelectItem> <SelectItem value="foreign-key-display">{{ t("grid.formatterForeignKeyDisplay") }}</SelectItem>
<SelectItem value="custom-template">{{ t("grid.formatterCustomTemplate") }}</SelectItem> <SelectItem value="custom-template">{{ t("grid.formatterCustomTemplate") }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -12021,27 +12244,142 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
</div> </div>
<div v-else-if="formatterKind === 'foreign-key-display'" class="space-y-3"> <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"> <template v-if="!formatterForeignKeyManual">
<div class="text-[11px] text-muted-foreground">{{ t("grid.formatterForeignKeyTarget") }}</div> <div class="rounded border bg-muted/30 px-2.5 py-2 text-xs">
<div class="mt-0.5 truncate font-mono" :title="`${formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : ''}${formatterForeignKeyRefTable}.${formatterForeignKeyRefColumn}`"> <div class="text-[11px] text-muted-foreground">{{ t("grid.formatterForeignKeyTarget") }}</div>
{{ formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : "" }}{{ formatterForeignKeyRefTable }}.{{ formatterForeignKeyRefColumn }} <div class="mt-0.5 truncate font-mono" :title="`${formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : ''}${formatterForeignKeyRefTable}.${formatterForeignKeyRefColumn}`">
{{ formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : "" }}{{ formatterForeignKeyRefTable }}.{{ formatterForeignKeyRefColumn }}
</div>
</div> </div>
</div> <div class="space-y-1.5">
<div class="space-y-1.5"> <div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeyDisplayColumn") }}</div>
<div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeyDisplayColumn") }}</div> <SearchableSelect
<SearchableSelect :model-value="formatterForeignKeyDisplayColumn"
:model-value="formatterForeignKeyDisplayColumn" :options="formatterForeignKeyColumns.map((column) => column.name)"
:options="formatterForeignKeyColumns.map((column) => column.name)" :placeholder="formatterForeignKeyColumnsLoading ? t('common.loading') : t('grid.formatterForeignKeyDisplayColumnPlaceholder')"
:placeholder="formatterForeignKeyColumnsLoading ? t('common.loading') : t('grid.formatterForeignKeyDisplayColumnPlaceholder')" :search-placeholder="t('grid.searchColumn')"
:search-placeholder="t('grid.searchColumn')" :empty-text="formatterForeignKeyColumnsError || t('grid.noColumnsFound')"
:empty-text="formatterForeignKeyColumnsError || t('grid.noColumnsFound')" :loading-text="t('common.loading')"
:loading-text="t('common.loading')" :loading="formatterForeignKeyColumnsLoading"
:loading="formatterForeignKeyColumnsLoading" :trigger-class="['border border-input h-8 pl-2.5 text-xs']"
: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"
item-class="h-auto min-h-8 px-2 py-1.5 text-xs" @update:model-value="(value: any) => (formatterForeignKeyDisplayColumn = value)"
@update:model-value="(value: any) => (formatterForeignKeyDisplayColumn = value)" />
/> </div>
</div> </template>
<template v-else>
<div class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.formatterForeignKeyManualHint") }}</div>
<div v-if="formatterForeignKeyTargetError" class="rounded border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-[11px] text-destructive">{{ formatterForeignKeyTargetError }}</div>
<div class="grid grid-cols-2 gap-2">
<div class="min-w-0 space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeySchema") }}</div>
<SearchableSelect
:model-value="formatterForeignKeyRefSchema"
:options="formatterForeignKeySchemas"
:placeholder="formatterForeignKeySchemasLoading ? t('common.loading') : t('grid.formatterForeignKeySchemaPlaceholder')"
:search-placeholder="t('grid.formatterForeignKeySearchSchema')"
:empty-text="t('grid.formatterForeignKeyNoSchemas')"
:loading-text="t('common.loading')"
:loading="formatterForeignKeySchemasLoading"
: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) => selectFormatterForeignKeySchema(String(value), col.actualColIdx)"
/>
</div>
<div class="min-w-0 space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeyTable") }}</div>
<SearchableSelect
:model-value="formatterForeignKeyRefTable"
:options="formatterForeignKeyTables"
:placeholder="formatterForeignKeyTablesLoading ? t('common.loading') : t('grid.formatterForeignKeyTablePlaceholder')"
:search-placeholder="t('grid.formatterForeignKeySearchTable')"
:empty-text="formatterForeignKeyTargetError || t('grid.formatterForeignKeyNoTables')"
:loading-text="t('common.loading')"
:loading="formatterForeignKeyTablesLoading"
: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) => selectFormatterForeignKeyTable(String(value), col.actualColIdx)"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="min-w-0 space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">{{ t("grid.formatterForeignKeyReferenceColumn") }}</div>
<SearchableSelect
:model-value="formatterForeignKeyRefColumn"
:options="formatterForeignKeyReferenceColumns.map((column) => column.name)"
:placeholder="formatterForeignKeyColumnsLoading ? t('common.loading') : t('grid.formatterForeignKeyReferenceColumnPlaceholder')"
: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) => (formatterForeignKeyRefColumn = String(value))"
/>
<div v-if="formatterForeignKeyReferenceValidation === 'unavailable'" class="text-[11px] leading-4 text-destructive">
{{ t("grid.formatterForeignKeyReferenceMetadataUnavailable", { error: formatterForeignKeyReferenceMetadataError }) }}
</div>
<div v-else-if="formatterForeignKeyReferenceValidation === 'invalid' && formatterForeignKeyRefColumn" class="text-[11px] leading-4 text-destructive">
{{ t("grid.formatterForeignKeyReferenceInvalid") }}
</div>
<div v-else-if="formatterForeignKeyReferenceMetadataStatus === 'available' && formatterForeignKeyRefTable && !formatterForeignKeyReferenceColumns.length" class="text-[11px] leading-4 text-destructive">
{{ t("grid.formatterForeignKeyReferenceUnavailable") }}
</div>
</div>
<div class="min-w-0 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 = String(value))"
/>
</div>
</div>
<div class="space-y-2 border-t pt-2.5">
<label class="flex items-center justify-between gap-3 text-xs font-medium">
<span>{{ t("grid.formatterForeignKeyFilter") }}</span>
<Switch v-model="formatterForeignKeyFilterEnabled" size="sm" />
</label>
<div v-if="formatterForeignKeyFilterEnabled" class="grid grid-cols-2 gap-2">
<SearchableSelect
:model-value="formatterForeignKeyFilterColumn"
:options="formatterForeignKeyColumns.map((column) => column.name)"
:placeholder="t('grid.formatterForeignKeyFilterColumnPlaceholder')"
: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) => (formatterForeignKeyFilterColumn = String(value))"
/>
<Select :model-value="formatterForeignKeyFilterMode" @update:model-value="(value: any) => selectFormatterForeignKeyFilterMode(value)">
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="option in filterModeOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem>
</SelectContent>
</Select>
<template v-if="filterModeNeedsValue(formatterForeignKeyFilterMode)">
<Input
v-model="formatterForeignKeyFilterValue"
class="h-8 text-xs"
:class="filterModeUsesRange(formatterForeignKeyFilterMode) ? '' : 'col-span-2'"
:placeholder="filterModeUsesList(formatterForeignKeyFilterMode) ? t('grid.filterBuilderValues') : filterModeUsesRange(formatterForeignKeyFilterMode) ? t('grid.filterBuilderRangeStart') : t('grid.filterBuilderValue')"
/>
<Input v-if="filterModeUsesRange(formatterForeignKeyFilterMode)" v-model="formatterForeignKeyFilterEndValue" class="h-8 text-xs" :placeholder="t('grid.filterBuilderRangeEnd')" />
</template>
</div>
</div>
</template>
<div class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.formatterForeignKeyHint") }}</div> <div class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.formatterForeignKeyHint") }}</div>
</div> </div>
@@ -12107,7 +12445,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
</div> </div>
</div> </div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2"> <div class="flex shrink-0 items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!columnHasFormatter(col.actualColIdx)" @click="clearColumnFormatter(col.actualColIdx)"> <Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!columnHasFormatter(col.actualColIdx)" @click="clearColumnFormatter(col.actualColIdx)">
{{ t("grid.clearFormatter") }} {{ t("grid.clearFormatter") }}
</Button> </Button>
+17 -1
View File
@@ -1717,9 +1717,25 @@ export default {
formatterMask: "Mask text", formatterMask: "Mask text",
formatterForeignKeyDisplay: "Foreign key label", formatterForeignKeyDisplay: "Foreign key label",
formatterForeignKeyTarget: "Referenced key", formatterForeignKeyTarget: "Referenced key",
formatterForeignKeyManualHint: "This column has no single-column foreign key. Select the referenced table and matching column manually.",
formatterForeignKeySchema: "Schema",
formatterForeignKeySchemaPlaceholder: "Select a schema",
formatterForeignKeySearchSchema: "Search schemas...",
formatterForeignKeyNoSchemas: "No schemas available",
formatterForeignKeyTable: "Referenced table",
formatterForeignKeyTablePlaceholder: "Select a referenced table",
formatterForeignKeySearchTable: "Search referenced tables...",
formatterForeignKeyNoTables: "No referenced tables available",
formatterForeignKeyReferenceColumn: "Matching column",
formatterForeignKeyReferenceColumnPlaceholder: "Select the column matching this value",
formatterForeignKeyReferenceMetadataUnavailable: "Could not verify matching-column uniqueness: {error}",
formatterForeignKeyReferenceInvalid: "This column is missing or is not a valid single-column unique key. Select another column.",
formatterForeignKeyReferenceUnavailable: "The target table has no usable single-column unique key.",
formatterForeignKeyDisplayColumn: "Display column", formatterForeignKeyDisplayColumn: "Display column",
formatterForeignKeyDisplayColumnPlaceholder: "Select a 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.", formatterForeignKeyFilter: "Limit referenced rows",
formatterForeignKeyFilterColumnPlaceholder: "Select a filter column",
formatterForeignKeyHint: "Loads labels for matching keys on the current page. Editing, filtering, sorting, copying, and export continue to use raw values.",
formatterCustomTemplate: "Custom template", formatterCustomTemplate: "Custom template",
formatterTimestampUnit: "Timestamp unit", formatterTimestampUnit: "Timestamp unit",
formatterUnitAuto: "Auto", formatterUnitAuto: "Auto",
+17 -1
View File
@@ -1601,9 +1601,25 @@ export default withEnglishFallback({
formatterMask: "Enmascarar texto", formatterMask: "Enmascarar texto",
formatterForeignKeyDisplay: "Etiqueta de clave externa", formatterForeignKeyDisplay: "Etiqueta de clave externa",
formatterForeignKeyTarget: "Clave referenciada", formatterForeignKeyTarget: "Clave referenciada",
formatterForeignKeyManualHint: "Esta columna no tiene una clave externa de una sola columna. Seleccione manualmente la tabla y la columna relacionadas.",
formatterForeignKeySchema: "Esquema",
formatterForeignKeySchemaPlaceholder: "Seleccionar un esquema",
formatterForeignKeySearchSchema: "Buscar esquemas...",
formatterForeignKeyNoSchemas: "No hay esquemas disponibles",
formatterForeignKeyTable: "Tabla referenciada",
formatterForeignKeyTablePlaceholder: "Seleccionar una tabla referenciada",
formatterForeignKeySearchTable: "Buscar tablas referenciadas...",
formatterForeignKeyNoTables: "No hay tablas referenciadas disponibles",
formatterForeignKeyReferenceColumn: "Columna relacionada",
formatterForeignKeyReferenceColumnPlaceholder: "Seleccionar la columna que coincide con este valor",
formatterForeignKeyReferenceMetadataUnavailable: "No se pudo verificar la unicidad de la columna relacionada: {error}",
formatterForeignKeyReferenceInvalid: "La columna no existe o no es una clave única válida de una sola columna. Seleccione otra columna.",
formatterForeignKeyReferenceUnavailable: "La tabla de destino no tiene ninguna clave única utilizable de una sola columna.",
formatterForeignKeyDisplayColumn: "Columna de visualización", formatterForeignKeyDisplayColumn: "Columna de visualización",
formatterForeignKeyDisplayColumnPlaceholder: "Seleccionar una columna", 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.", formatterForeignKeyFilter: "Limitar filas referenciadas",
formatterForeignKeyFilterColumnPlaceholder: "Seleccionar una columna de filtro",
formatterForeignKeyHint: "Carga etiquetas para las claves coincidentes 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", formatterCustomTemplate: "Plantilla personalizada",
formatterTimestampUnit: "Unidad de marca de tiempo", formatterTimestampUnit: "Unidad de marca de tiempo",
formatterUnitAuto: "Automático", formatterUnitAuto: "Automático",
+17 -1
View File
@@ -1599,9 +1599,25 @@ export default withEnglishFallback({
formatterMask: "Maschera testo", formatterMask: "Maschera testo",
formatterForeignKeyDisplay: "Etichetta chiave esterna", formatterForeignKeyDisplay: "Etichetta chiave esterna",
formatterForeignKeyTarget: "Chiave referenziata", formatterForeignKeyTarget: "Chiave referenziata",
formatterForeignKeyManualHint: "Questa colonna non ha una chiave esterna su una sola colonna. Seleziona manualmente la tabella e la colonna correlate.",
formatterForeignKeySchema: "Schema",
formatterForeignKeySchemaPlaceholder: "Seleziona uno schema",
formatterForeignKeySearchSchema: "Cerca schemi...",
formatterForeignKeyNoSchemas: "Nessuno schema disponibile",
formatterForeignKeyTable: "Tabella referenziata",
formatterForeignKeyTablePlaceholder: "Seleziona una tabella referenziata",
formatterForeignKeySearchTable: "Cerca tabelle referenziate...",
formatterForeignKeyNoTables: "Nessuna tabella referenziata disponibile",
formatterForeignKeyReferenceColumn: "Colonna correlata",
formatterForeignKeyReferenceColumnPlaceholder: "Seleziona la colonna corrispondente a questo valore",
formatterForeignKeyReferenceMetadataUnavailable: "Impossibile verificare l'unicità della colonna correlata: {error}",
formatterForeignKeyReferenceInvalid: "La colonna non esiste o non è una chiave univoca valida a colonna singola. Seleziona un'altra colonna.",
formatterForeignKeyReferenceUnavailable: "La tabella di destinazione non ha chiavi univoche utilizzabili a colonna singola.",
formatterForeignKeyDisplayColumn: "Colonna visualizzata", formatterForeignKeyDisplayColumn: "Colonna visualizzata",
formatterForeignKeyDisplayColumnPlaceholder: "Seleziona una colonna", formatterForeignKeyDisplayColumnPlaceholder: "Seleziona una colonna",
formatterForeignKeyHint: "Carica le etichette per le chiavi della pagina corrente. Modifica, filtro, ordinamento, copia ed esportazione usano i valori originali.", formatterForeignKeyFilter: "Limita le righe referenziate",
formatterForeignKeyFilterColumnPlaceholder: "Seleziona una colonna filtro",
formatterForeignKeyHint: "Carica le etichette per le chiavi corrispondenti della pagina corrente. Modifica, filtro, ordinamento, copia ed esportazione usano i valori originali.",
formatterCustomTemplate: "Modello personalizzato", formatterCustomTemplate: "Modello personalizzato",
formatterTimestampUnit: "Unità timestamp", formatterTimestampUnit: "Unità timestamp",
formatterUnitAuto: "Auto", formatterUnitAuto: "Auto",
+17 -1
View File
@@ -1617,9 +1617,25 @@ export default withEnglishFallback({
formatterMask: "テキストをマスク", formatterMask: "テキストをマスク",
formatterForeignKeyDisplay: "外部キーラベル", formatterForeignKeyDisplay: "外部キーラベル",
formatterForeignKeyTarget: "参照キー", formatterForeignKeyTarget: "参照キー",
formatterForeignKeyManualHint: "この列には単一列の外部キーがありません。参照テーブルと対応列を手動で指定してください。",
formatterForeignKeySchema: "スキーマ",
formatterForeignKeySchemaPlaceholder: "スキーマを選択",
formatterForeignKeySearchSchema: "スキーマを検索...",
formatterForeignKeyNoSchemas: "利用可能なスキーマがありません",
formatterForeignKeyTable: "参照テーブル",
formatterForeignKeyTablePlaceholder: "参照テーブルを選択",
formatterForeignKeySearchTable: "参照テーブルを検索...",
formatterForeignKeyNoTables: "利用可能な参照テーブルがありません",
formatterForeignKeyReferenceColumn: "対応列",
formatterForeignKeyReferenceColumnPlaceholder: "現在の値に対応する列を選択",
formatterForeignKeyReferenceMetadataUnavailable: "対応列の一意性を確認できませんでした:{error}",
formatterForeignKeyReferenceInvalid: "この列は存在しないか、有効な単一列の一意キーではありません。別の列を選択してください。",
formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な単一列の一意キーがありません。",
formatterForeignKeyDisplayColumn: "表示列", formatterForeignKeyDisplayColumn: "表示列",
formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択", formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択",
formatterForeignKeyHint: "現在のページのキーに対応するラベルのみ読み込みます。編集、フィルター、並べ替え、コピー、エクスポートでは元の値を使用します。", formatterForeignKeyFilter: "参照行を絞り込む",
formatterForeignKeyFilterColumnPlaceholder: "フィルター列を選択",
formatterForeignKeyHint: "現在のページの対応キーに対するラベルのみ読み込みます。編集、フィルター、並べ替え、コピー、エクスポートでは元の値を使用します。",
formatterCustomTemplate: "カスタムテンプレート", formatterCustomTemplate: "カスタムテンプレート",
formatterTimestampUnit: "タイムスタンプ単位", formatterTimestampUnit: "タイムスタンプ単位",
formatterUnitAuto: "自動", formatterUnitAuto: "自動",
+17 -1
View File
@@ -1601,9 +1601,25 @@ export default withEnglishFallback({
formatterMask: "Mascarar texto", formatterMask: "Mascarar texto",
formatterForeignKeyDisplay: "Rótulo de chave estrangeira", formatterForeignKeyDisplay: "Rótulo de chave estrangeira",
formatterForeignKeyTarget: "Chave referenciada", formatterForeignKeyTarget: "Chave referenciada",
formatterForeignKeyManualHint: "Esta coluna não possui uma chave estrangeira de coluna única. Selecione manualmente a tabela e a coluna relacionadas.",
formatterForeignKeySchema: "Schema",
formatterForeignKeySchemaPlaceholder: "Selecione um schema",
formatterForeignKeySearchSchema: "Pesquisar schemas...",
formatterForeignKeyNoSchemas: "Nenhum schema disponível",
formatterForeignKeyTable: "Tabela referenciada",
formatterForeignKeyTablePlaceholder: "Selecione uma tabela referenciada",
formatterForeignKeySearchTable: "Pesquisar tabelas referenciadas...",
formatterForeignKeyNoTables: "Nenhuma tabela referenciada disponível",
formatterForeignKeyReferenceColumn: "Coluna relacionada",
formatterForeignKeyReferenceColumnPlaceholder: "Selecione a coluna correspondente a este valor",
formatterForeignKeyReferenceMetadataUnavailable: "Não foi possível verificar a exclusividade da coluna relacionada: {error}",
formatterForeignKeyReferenceInvalid: "A coluna não existe ou não é uma chave exclusiva válida de uma única coluna. Selecione outra coluna.",
formatterForeignKeyReferenceUnavailable: "A tabela de destino não tem uma chave exclusiva utilizável de uma única coluna.",
formatterForeignKeyDisplayColumn: "Coluna de exibição", formatterForeignKeyDisplayColumn: "Coluna de exibição",
formatterForeignKeyDisplayColumnPlaceholder: "Selecione uma coluna", 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.", formatterForeignKeyFilter: "Limitar linhas referenciadas",
formatterForeignKeyFilterColumnPlaceholder: "Selecione uma coluna de filtro",
formatterForeignKeyHint: "Carrega rótulos para as chaves correspondentes da página atual. Edição, filtro, ordenação, cópia e exportação continuam usando os valores originais.",
formatterCustomTemplate: "Template personalizado", formatterCustomTemplate: "Template personalizado",
formatterTimestampUnit: "Unidade do timestamp", formatterTimestampUnit: "Unidade do timestamp",
formatterUnitAuto: "Automático", formatterUnitAuto: "Automático",
+17 -1
View File
@@ -1642,9 +1642,25 @@ export default withEnglishFallback({
formatterMask: "文本脱敏", formatterMask: "文本脱敏",
formatterForeignKeyDisplay: "外键显示值", formatterForeignKeyDisplay: "外键显示值",
formatterForeignKeyTarget: "引用键", formatterForeignKeyTarget: "引用键",
formatterForeignKeyManualHint: "当前字段没有单列外键。请手动指定引用表和关联字段。",
formatterForeignKeySchema: "Schema",
formatterForeignKeySchemaPlaceholder: "选择 Schema",
formatterForeignKeySearchSchema: "搜索 Schema...",
formatterForeignKeyNoSchemas: "没有可用的 Schema",
formatterForeignKeyTable: "引用表",
formatterForeignKeyTablePlaceholder: "选择引用表",
formatterForeignKeySearchTable: "搜索引用表...",
formatterForeignKeyNoTables: "没有可用的引用表",
formatterForeignKeyReferenceColumn: "关联字段",
formatterForeignKeyReferenceColumnPlaceholder: "选择与当前字段匹配的字段",
formatterForeignKeyReferenceMetadataUnavailable: "无法验证关联字段唯一性:{error}",
formatterForeignKeyReferenceInvalid: "该字段不存在,或不是有效的单列唯一键。请重新选择。",
formatterForeignKeyReferenceUnavailable: "目标表没有可用的单列唯一键。",
formatterForeignKeyDisplayColumn: "显示字段", formatterForeignKeyDisplayColumn: "显示字段",
formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段", formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段",
formatterForeignKeyHint: "仅加载当前页外键对应的显示值;编辑、筛选、排序、复制和导出仍使用原始值。", formatterForeignKeyFilter: "限定引用数据",
formatterForeignKeyFilterColumnPlaceholder: "选择筛选字段",
formatterForeignKeyHint: "仅加载当前页关联键对应的显示值;编辑、筛选、排序、复制和导出仍使用原始值。",
formatterCustomTemplate: "自定义模板", formatterCustomTemplate: "自定义模板",
formatterTimestampUnit: "时间戳单位", formatterTimestampUnit: "时间戳单位",
formatterUnitAuto: "自动", formatterUnitAuto: "自动",
+17 -1
View File
@@ -1600,9 +1600,25 @@ export default withEnglishFallback({
formatterMask: "文字遮罩", formatterMask: "文字遮罩",
formatterForeignKeyDisplay: "外鍵顯示值", formatterForeignKeyDisplay: "外鍵顯示值",
formatterForeignKeyTarget: "引用鍵", formatterForeignKeyTarget: "引用鍵",
formatterForeignKeyManualHint: "目前欄位沒有單欄外鍵。請手動指定引用資料表和關聯欄位。",
formatterForeignKeySchema: "Schema",
formatterForeignKeySchemaPlaceholder: "選擇 Schema",
formatterForeignKeySearchSchema: "搜尋 Schema...",
formatterForeignKeyNoSchemas: "沒有可用的 Schema",
formatterForeignKeyTable: "引用資料表",
formatterForeignKeyTablePlaceholder: "選擇引用資料表",
formatterForeignKeySearchTable: "搜尋引用資料表...",
formatterForeignKeyNoTables: "沒有可用的引用資料表",
formatterForeignKeyReferenceColumn: "關聯欄位",
formatterForeignKeyReferenceColumnPlaceholder: "選擇與目前欄位匹配的欄位",
formatterForeignKeyReferenceMetadataUnavailable: "無法驗證關聯欄位的唯一性:{error}",
formatterForeignKeyReferenceInvalid: "此欄位不存在,或不是有效的單欄唯一鍵。請重新選擇。",
formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的單欄唯一鍵。",
formatterForeignKeyDisplayColumn: "顯示欄位", formatterForeignKeyDisplayColumn: "顯示欄位",
formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位", formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位",
formatterForeignKeyHint: "僅載入目前頁面外鍵對應的顯示值;編輯、篩選、排序、複製與匯出仍使用原始值。", formatterForeignKeyFilter: "限定引用資料",
formatterForeignKeyFilterColumnPlaceholder: "選擇篩選欄位",
formatterForeignKeyHint: "僅載入目前頁面關聯鍵對應的顯示值;編輯、篩選、排序、複製與匯出仍使用原始值。",
formatterCustomTemplate: "自訂範本", formatterCustomTemplate: "自訂範本",
formatterTimestampUnit: "時間戳單位", formatterTimestampUnit: "時間戳單位",
formatterUnitAuto: "自動", formatterUnitAuto: "自動",
@@ -1,5 +1,42 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyColumnFormatter, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter"; import { applyColumnFormatter, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeColumnFormatter, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter";
describe("normalizeColumnFormatter", () => {
it("preserves a structured manual reference filter", () => {
expect(
normalizeColumnFormatter({
kind: "foreign-key-display",
referenceMode: "manual",
refSchema: " public ",
refTable: " dictionary ",
refColumn: " dict_key ",
displayColumn: " dict_value ",
filter: { column: " dict_type ", mode: "equals", value: " order_status " },
}),
).toEqual({
kind: "foreign-key-display",
referenceMode: "manual",
refSchema: "public",
refTable: "dictionary",
refColumn: "dict_key",
displayColumn: "dict_value",
filter: { column: "dict_type", mode: "equals", value: "order_status", endValue: undefined },
});
});
it("rejects incomplete or unsupported manual reference filters", () => {
const base = {
kind: "foreign-key-display",
referenceMode: "manual",
refTable: "dictionary",
refColumn: "dict_key",
displayColumn: "dict_value",
};
expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "equals", value: "" } })).toBeUndefined();
expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "between", value: "A" } })).toBeUndefined();
expect(normalizeColumnFormatter({ ...base, filter: { column: "dict_type", mode: "raw-sql", value: "1=1" } })).toBeUndefined();
});
});
describe("normalizeSupportedDateTimePattern", () => { describe("normalizeSupportedDateTimePattern", () => {
it("accepts the format grammar shared by the frontend and backend", () => { it("accepts the format grammar shared by the frontend and backend", () => {
@@ -2,15 +2,20 @@ import { describe, expect, it } from "vitest";
import { import {
collectForeignKeyDisplayValues, collectForeignKeyDisplayValues,
createForeignKeyDisplayRequestCoordinator, createForeignKeyDisplayRequestCoordinator,
foreignKeyDisplayConfigIsUsable,
foreignKeyDisplayConfigMatches, foreignKeyDisplayConfigMatches,
foreignKeyDisplayLookupRequestKey, foreignKeyDisplayLookupRequestKey,
foreignKeyDisplayMapFromResult, foreignKeyDisplayMapFromResult,
formatForeignKeyDisplayValue, formatForeignKeyDisplayValue,
manualReferenceColumnValidation,
manualReferenceKeyColumnIsUnique,
manualReferenceKeyColumns,
reconcileManualReferenceColumn,
singleColumnForeignKey, singleColumnForeignKey,
splitForeignKeyDisplayValues, splitForeignKeyDisplayValues,
} from "@/lib/dataGrid/dataGridForeignKeyDisplay"; } from "@/lib/dataGrid/dataGridForeignKeyDisplay";
import { buildColumnForeignKeyMap } from "@/lib/dataGrid/dataGridForeignKeyNavigation"; import { buildColumnForeignKeyMap } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
import type { QueryResult } from "@/types/database"; import type { ColumnInfo, QueryResult } from "@/types/database";
function deferred<T>() { function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void; let resolve!: (value: T | PromiseLike<T>) => void;
@@ -41,6 +46,46 @@ describe("dataGridForeignKeyDisplay", () => {
expect(foreignKeyDisplayConfigMatches(config, { name: "fk_user", column: "user_id", ref_schema: "archive", ref_table: "users", 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("allows explicit manual references without foreign-key metadata", () => {
const manual = {
kind: "foreign-key-display" as const,
referenceMode: "manual" as const,
refTable: "dictionary",
refColumn: "dict_key",
displayColumn: "dict_value",
};
const automatic = { ...manual, referenceMode: "foreign-key" as const };
expect(foreignKeyDisplayConfigIsUsable(manual, undefined)).toBe(true);
expect(foreignKeyDisplayConfigIsUsable(automatic, undefined)).toBe(false);
expect(foreignKeyDisplayConfigIsUsable(automatic, { name: "fk_dict", column: "status", ref_table: "dictionary", ref_column: "dict_key" })).toBe(true);
});
it("uses backend-approved reference keys with exact metadata identity", () => {
const columns = [
{ name: "tenant_id", is_primary_key: true },
{ name: "record_id", is_primary_key: true },
{ name: "code", is_primary_key: false },
{ name: "Code", is_primary_key: false },
].map((column) => ({ data_type: "varchar", is_nullable: false, column_default: null, extra: null, ...column })) as ColumnInfo[];
expect(manualReferenceKeyColumns(columns, ["code"]).map((column) => column.name)).toEqual(["code"]);
expect(manualReferenceKeyColumnIsUnique(columns, ["code"], "code")).toBe(true);
expect(manualReferenceKeyColumnIsUnique(columns, ["code"], "Code")).toBe(false);
expect(manualReferenceKeyColumnIsUnique(columns, [], "tenant_id")).toBe(false);
});
it("preserves saved reference columns when metadata is invalid or unavailable", () => {
const columns = [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }] as ColumnInfo[];
expect(reconcileManualReferenceColumn("legacy_code", columns, "available")).toBe("legacy_code");
expect(reconcileManualReferenceColumn("legacy_code", [], "unavailable")).toBe("legacy_code");
expect(reconcileManualReferenceColumn("", columns, "available")).toBe("id");
expect(reconcileManualReferenceColumn("", columns, "unavailable")).toBe("");
expect(manualReferenceColumnValidation(columns, [], "legacy_code", "available")).toBe("invalid");
expect(manualReferenceColumnValidation(columns, [], "legacy_code", "unavailable")).toBe("unavailable");
});
it("deduplicates current-page keys with type-safe identities and bounded batches", () => { 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 rows = [[100], [100], ["100"], [null], [101], [{ id: 1 }]] as QueryResult["rows"];
const values = collectForeignKeyDisplayValues(rows, 0); const values = collectForeignKeyDisplayValues(rows, 0);
@@ -48,6 +93,13 @@ describe("dataGridForeignKeyDisplay", () => {
expect(splitForeignKeyDisplayValues(values, 2)).toEqual([[100, "100"], [101]]); expect(splitForeignKeyDisplayValues(values, 2)).toEqual([[100, "100"], [101]]);
}); });
it("deduplicates SQL-equivalent numeric values without collapsing text keys", () => {
const rows = [[100], ["100.0"], ["1e2"], ["0100"], [101]] as QueryResult["rows"];
expect(collectForeignKeyDisplayValues(rows, 0, "decimal(20, 4)")).toEqual([100, 101]);
expect(collectForeignKeyDisplayValues(rows, 0, "varchar")).toEqual([100, "100.0", "1e2", "0100", 101]);
});
it("caps a large page at 2000 unique values and four 500-value batches", () => { 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 rows = Array.from({ length: 2500 }, (_, index) => [index]) as QueryResult["rows"];
const values = collectForeignKeyDisplayValues(rows, 0); const values = collectForeignKeyDisplayValues(rows, 0);
@@ -70,17 +122,47 @@ describe("dataGridForeignKeyDisplay", () => {
], ],
} as QueryResult; } as QueryResult;
const labels = foreignKeyDisplayMapFromResult(result); const labels = foreignKeyDisplayMapFromResult(result);
const codeLabels = foreignKeyDisplayMapFromResult(result, "ID", "CODE"); const codeLabels = foreignKeyDisplayMapFromResult(result, "id", "code");
const mismatchedCaseLabels = foreignKeyDisplayMapFromResult(result, "ID", "CODE");
expect(formatForeignKeyDisplayValue(100, labels)).toBe("100 (张三)"); expect(formatForeignKeyDisplayValue(100, labels)).toBe("100 (张三)");
expect(formatForeignKeyDisplayValue(102, labels)).toBe("102"); expect(formatForeignKeyDisplayValue(102, labels)).toBe("102");
expect(formatForeignKeyDisplayValue(103, labels)).toBe("103"); expect(formatForeignKeyDisplayValue(103, labels)).toBe("103");
expect(formatForeignKeyDisplayValue(null, labels)).toBe("NULL"); expect(formatForeignKeyDisplayValue(null, labels)).toBe("NULL");
expect(mismatchedCaseLabels.size).toBe(0);
expect(formatForeignKeyDisplayValue("same", new Map([["string\u0000same", "same"]]))).toBe("same"); expect(formatForeignKeyDisplayValue("same", new Map([["string\u0000same", "same"]]))).toBe("same");
expect(formatForeignKeyDisplayValue(100, codeLabels)).toBe("100 (U100)"); expect(formatForeignKeyDisplayValue(100, codeLabels)).toBe("100 (U100)");
expect(foreignKeyDisplayMapFromResult(result, "missing", "code")).toEqual(new Map()); expect(foreignKeyDisplayMapFromResult(result, "missing", "code")).toEqual(new Map());
}); });
it("matches numeric driver strings while preserving distinct text values", () => {
const numericResult = { columns: ["id", "name"], rows: [["100.00", "numeric label"]] } as QueryResult;
const numericLabels = foreignKeyDisplayMapFromResult(numericResult, "id", "name", "decimal(20, 2)");
expect(formatForeignKeyDisplayValue(100, numericLabels, "decimal(20, 2)")).toBe("100 (numeric label)");
const textResult = {
columns: ["id", "name"],
rows: [
["01", "leading zero"],
["1", "plain"],
],
} as QueryResult;
const textLabels = foreignKeyDisplayMapFromResult(textResult, "id", "name", "varchar");
expect(formatForeignKeyDisplayValue("01", textLabels, "varchar")).toBe("01 (leading zero)");
expect(formatForeignKeyDisplayValue("1", textLabels, "varchar")).toBe("1 (plain)");
const largeNumericResult = {
columns: ["id", "name"],
rows: [
["9007199254740992", "even"],
["9007199254740993", "odd"],
],
} as QueryResult;
const largeNumericLabels = foreignKeyDisplayMapFromResult(largeNumericResult, "id", "name", "decimal(20, 0)");
expect(formatForeignKeyDisplayValue("9007199254740992", largeNumericLabels, "decimal(20, 0)")).toBe("9007199254740992 (even)");
expect(formatForeignKeyDisplayValue("9007199254740993", largeNumericLabels, "decimal(20, 0)")).toBe("9007199254740993 (odd)");
});
it("deduplicates in-flight requests and reuses the bounded cache across generations", async () => { it("deduplicates in-flight requests and reuses the bounded cache across generations", async () => {
const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 }); const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 });
const gate = deferred<number>(); const gate = deferred<number>();
@@ -94,6 +176,13 @@ describe("dataGridForeignKeyDisplay", () => {
const firstKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: [100, "100"] }); const firstKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: [100, "100"] });
const reorderedKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: ["100", 100] }); const reorderedKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: ["100", 100] });
expect(reorderedKey).toBe(firstKey); expect(reorderedKey).toBe(firstKey);
expect(
foreignKeyDisplayLookupRequestKey({
...requestScope,
filter: { column: "dict_type", mode: "equals", value: "order_status" },
values: [100, "100"],
}),
).not.toBe(firstKey);
const first = coordinator.request(generation, firstKey, task); const first = coordinator.request(generation, firstKey, task);
const duplicate = coordinator.request(generation, reorderedKey, task); const duplicate = coordinator.request(generation, reorderedKey, task);
+1
View File
@@ -172,6 +172,7 @@ export const getAllColumns = forward("getAllColumns");
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
export const listDataTypes = forward("listDataTypes"); export const listDataTypes = forward("listDataTypes");
export const listIndexes = forward("listIndexes"); export const listIndexes = forward("listIndexes");
export const listReferenceKeyColumns = forward("listReferenceKeyColumns");
export const listForeignKeys = forward("listForeignKeys"); export const listForeignKeys = forward("listForeignKeys");
export const listTriggers = forward("listTriggers"); export const listTriggers = forward("listTriggers");
export const listConstraints = forward("listConstraints"); export const listConstraints = forward("listConstraints");
+4
View File
@@ -891,6 +891,10 @@ export async function listIndexes(connectionId: string, database: string, schema
return get(`/api/schema/indexes?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); return get(`/api/schema/indexes?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
} }
export async function listReferenceKeyColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<string[]> {
return get(`/api/schema/reference-key-columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
}
export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ForeignKeyInfo[]> { export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ForeignKeyInfo[]> {
return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table, catalog })}`);
} }
+10
View File
@@ -1726,6 +1726,16 @@ export async function listIndexes(connectionId: string, database: string, schema
}); });
} }
export async function listReferenceKeyColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<string[]> {
return invoke("list_reference_key_columns", {
connectionId,
database,
schema,
table,
catalog,
});
}
export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ForeignKeyInfo[]> { export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise<ForeignKeyInfo[]> {
return invoke("list_foreign_keys", { return invoke("list_foreign_keys", {
connectionId, connectionId,
@@ -1,4 +1,5 @@
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
@@ -60,6 +61,13 @@ export interface CustomColumnFormatterConfig {
template: string; template: string;
} }
export interface ForeignKeyDisplayFilterConfig {
column: string;
mode: DataGridContextFilterMode;
value?: string;
endValue?: string;
}
interface IntlTimeZoneSupport { interface IntlTimeZoneSupport {
supportedValuesOf?: (key: "timeZone") => string[]; supportedValuesOf?: (key: "timeZone") => string[];
} }
@@ -105,7 +113,15 @@ export type ColumnFormatterConfig =
| { kind: "iotdb-timestamp"; precision: IoTDBTimestampPrecision; timezone: string } | { kind: "iotdb-timestamp"; precision: IoTDBTimestampPrecision; timezone: string }
| { kind: "json-path"; path: string } | { kind: "json-path"; path: string }
| { kind: "mask"; prefix: number; suffix: number } | { kind: "mask"; prefix: number; suffix: number }
| { kind: "foreign-key-display"; refSchema?: string; refTable: string; refColumn: string; displayColumn: string } | {
kind: "foreign-key-display";
referenceMode?: "foreign-key" | "manual";
refSchema?: string;
refTable: string;
refColumn: string;
displayColumn: string;
filter?: ForeignKeyDisplayFilterConfig;
}
| { kind: "custom-template"; template: string } | { kind: "custom-template"; template: string }
| { kind: "custom-ref"; formatterId: string }; | { kind: "custom-ref"; formatterId: string };
@@ -150,12 +166,16 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
if (typeof config.refTable !== "string" || !config.refTable.trim()) return undefined; if (typeof config.refTable !== "string" || !config.refTable.trim()) return undefined;
if (typeof config.refColumn !== "string" || !config.refColumn.trim()) return undefined; if (typeof config.refColumn !== "string" || !config.refColumn.trim()) return undefined;
if (typeof config.displayColumn !== "string" || !config.displayColumn.trim()) return undefined; if (typeof config.displayColumn !== "string" || !config.displayColumn.trim()) return undefined;
const filter = normalizeForeignKeyDisplayFilter(config.filter);
if (config.filter !== undefined && !filter) return undefined;
return { return {
kind: "foreign-key-display", kind: "foreign-key-display",
...(config.referenceMode === "manual" || config.referenceMode === "foreign-key" ? { referenceMode: config.referenceMode } : {}),
refSchema: typeof config.refSchema === "string" && config.refSchema.trim() ? config.refSchema.trim() : undefined, refSchema: typeof config.refSchema === "string" && config.refSchema.trim() ? config.refSchema.trim() : undefined,
refTable: config.refTable.trim(), refTable: config.refTable.trim(),
refColumn: config.refColumn.trim(), refColumn: config.refColumn.trim(),
displayColumn: config.displayColumn.trim(), displayColumn: config.displayColumn.trim(),
...(filter ? { filter } : {}),
}; };
} }
@@ -170,6 +190,26 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig
return undefined; return undefined;
} }
const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set<DataGridContextFilterMode>(["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "less-than-or-equal", "greater-than", "greater-than-or-equal", "in", "not-in", "between", "not-between"]);
function normalizeForeignKeyDisplayFilter(value: unknown): ForeignKeyDisplayFilterConfig | undefined {
if (!value || typeof value !== "object") return undefined;
const filter = value as Record<string, unknown>;
if (typeof filter.column !== "string" || !filter.column.trim()) return undefined;
if (typeof filter.mode !== "string" || !FOREIGN_KEY_DISPLAY_FILTER_MODES.has(filter.mode as DataGridContextFilterMode)) return undefined;
const mode = filter.mode as DataGridContextFilterMode;
const rawValue = typeof filter.value === "string" ? filter.value.trim() : "";
const rawEndValue = typeof filter.endValue === "string" ? filter.endValue.trim() : "";
if (mode !== "is-null" && mode !== "is-not-null" && !rawValue) return undefined;
if ((mode === "between" || mode === "not-between") && !rawEndValue) return undefined;
return {
column: filter.column.trim(),
mode,
value: rawValue || undefined,
endValue: rawEndValue || undefined,
};
}
export function normalizeCustomColumnFormatter(value: unknown): CustomColumnFormatterConfig | undefined { export function normalizeCustomColumnFormatter(value: unknown): CustomColumnFormatterConfig | undefined {
if (!value || typeof value !== "object") return undefined; if (!value || typeof value !== "object") return undefined;
const config = value as Record<string, unknown>; const config = value as Record<string, unknown>;
@@ -1,7 +1,8 @@
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import type { ColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter"; import type { ColumnFormatterConfig, ForeignKeyDisplayFilterConfig } from "@/lib/dataGrid/columnFormatter";
import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType";
import type { ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation"; import type { ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
import type { ForeignKeyInfo, QueryResult } from "@/types/database"; import type { ColumnInfo, ForeignKeyInfo, QueryResult } from "@/types/database";
export type ForeignKeyDisplayConfig = Extract<ColumnFormatterConfig, { kind: "foreign-key-display" }>; export type ForeignKeyDisplayConfig = Extract<ColumnFormatterConfig, { kind: "foreign-key-display" }>;
@@ -160,22 +161,71 @@ export function foreignKeyDisplayConfigMatches(config: ForeignKeyDisplayConfig,
return config.refTable.toLowerCase() === foreignKey.ref_table.toLowerCase() && config.refColumn.toLowerCase() === foreignKey.ref_column.toLowerCase() && expectedSchema.toLowerCase() === actualSchema.toLowerCase(); 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 { export function foreignKeyDisplayConfigIsUsable(config: ForeignKeyDisplayConfig, foreignKey: ForeignKeyInfo | undefined, currentSchema?: string): boolean {
if (config.referenceMode === "manual") return true;
return !!foreignKey && foreignKeyDisplayConfigMatches(config, foreignKey, currentSchema);
}
export type ManualReferenceMetadataStatus = "loading" | "available" | "unavailable";
export type ManualReferenceColumnValidation = "pending" | "valid" | "invalid" | "unavailable";
export function manualReferenceKeyColumns(columns: readonly ColumnInfo[], referenceKeyColumnNames: readonly string[]): ColumnInfo[] {
const uniqueColumnNames = new Set(referenceKeyColumnNames);
return columns.filter((column) => uniqueColumnNames.has(column.name));
}
export function manualReferenceKeyColumnIsUnique(columns: readonly ColumnInfo[], referenceKeyColumnNames: readonly string[], columnName: string): boolean {
return !!columnName && manualReferenceKeyColumns(columns, referenceKeyColumnNames).some((column) => column.name === columnName);
}
export function manualReferenceColumnValidation(columns: readonly ColumnInfo[], referenceKeyColumnNames: readonly string[], columnName: string, metadataStatus: ManualReferenceMetadataStatus): ManualReferenceColumnValidation {
if (metadataStatus === "loading") return "pending";
if (metadataStatus === "unavailable") return "unavailable";
return manualReferenceKeyColumnIsUnique(columns, referenceKeyColumnNames, columnName) ? "valid" : "invalid";
}
export function reconcileManualReferenceColumn(currentColumn: string, referenceColumns: readonly ColumnInfo[], metadataStatus: ManualReferenceMetadataStatus): string {
if (currentColumn || metadataStatus !== "available") return currentColumn;
return referenceColumns[0]?.name ?? "";
}
function canonicalNumericReferenceValue(value: string | number): string | undefined {
const text = typeof value === "number" ? (Number.isFinite(value) ? String(value) : "") : value.trim();
const match = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(text);
if (!match) return undefined;
const integerDigits = match[2] ?? "";
const fractionalDigits = match[3] ?? match[4] ?? "";
const explicitExponent = Number(match[5] ?? 0);
if (!Number.isSafeInteger(explicitExponent)) return undefined;
let digits = `${integerDigits}${fractionalDigits}`;
const firstNonZero = digits.search(/[1-9]/);
if (firstNonZero < 0) return "0";
let decimalPosition = integerDigits.length + explicitExponent - firstNonZero;
digits = digits.slice(firstNonZero).replace(/0+$/, "");
decimalPosition -= digits.length;
return `${match[1] === "-" ? "-" : ""}${digits}e${decimalPosition}`;
}
export function foreignKeyDisplayValueKey(value: CellValue | undefined, keyDataType?: string): string | undefined {
if (value === null || value === undefined || typeof value === "object") return undefined; if (value === null || value === undefined || typeof value === "object") return undefined;
if (isNumericColumnType(keyDataType) && (typeof value === "number" || typeof value === "string")) {
const numericValue = canonicalNumericReferenceValue(value);
if (numericValue !== undefined) return `numeric\u0000${numericValue}`;
}
return `${typeof value}\u0000${String(value)}`; 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 { export function foreignKeyDisplayLookupRequestKey(options: { connectionId: string; database?: string; catalog?: string; schema?: string; table: string; refColumn: string; displayColumn: string; keyDataType?: string; filter?: ForeignKeyDisplayFilterConfig; values: readonly CellValue[] }): string {
const valueKeys = options.values.map((value) => foreignKeyDisplayValueKey(value) ?? "").sort(); const valueKeys = options.values.map((value) => foreignKeyDisplayValueKey(value, options.keyDataType) ?? "").sort();
return JSON.stringify(["lookup", options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.table, options.refColumn, options.displayColumn, valueKeys]); return JSON.stringify(["lookup", options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.table, options.refColumn, options.displayColumn, options.keyDataType ?? "", options.filter ?? null, valueKeys]);
} }
export function collectForeignKeyDisplayValues(rows: QueryResult["rows"], columnIndex: number, maxValues = FOREIGN_KEY_DISPLAY_MAX_VALUES): CellValue[] { export function collectForeignKeyDisplayValues(rows: QueryResult["rows"], columnIndex: number, keyDataType?: string, maxValues = FOREIGN_KEY_DISPLAY_MAX_VALUES): CellValue[] {
const values: CellValue[] = []; const values: CellValue[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const row of rows) { for (const row of rows) {
const value = row[columnIndex] as CellValue | undefined; const value = row[columnIndex] as CellValue | undefined;
const key = foreignKeyDisplayValueKey(value); const key = foreignKeyDisplayValueKey(value, keyDataType);
if (!key || seen.has(key)) continue; if (!key || seen.has(key)) continue;
seen.add(key); seen.add(key);
values.push(value!); values.push(value!);
@@ -191,13 +241,13 @@ export function splitForeignKeyDisplayValues(values: readonly CellValue[], batch
return batches; return batches;
} }
export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1]): Map<string, string> { export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1], keyDataType?: string): Map<string, string> {
const map = new Map<string, string>(); const map = new Map<string, string>();
const keyIndex = result.columns.findIndex((column) => column.toLowerCase() === keyColumn?.toLowerCase()); const keyIndex = result.columns.findIndex((column) => column === keyColumn);
const displayIndex = result.columns.findIndex((column) => column.toLowerCase() === displayColumn?.toLowerCase()); const displayIndex = result.columns.findIndex((column) => column === displayColumn);
if (keyIndex < 0 || displayIndex < 0) return map; if (keyIndex < 0 || displayIndex < 0) return map;
for (const row of result.rows) { for (const row of result.rows) {
const key = foreignKeyDisplayValueKey(row[keyIndex] as CellValue | undefined); const key = foreignKeyDisplayValueKey(row[keyIndex] as CellValue | undefined, keyDataType);
const labelValue = row[displayIndex] as CellValue | undefined; const labelValue = row[displayIndex] as CellValue | undefined;
if (!key || labelValue === null || labelValue === undefined || map.has(key)) continue; if (!key || labelValue === null || labelValue === undefined || map.has(key)) continue;
map.set(key, displayCellValue(labelValue)); map.set(key, displayCellValue(labelValue));
@@ -205,9 +255,9 @@ export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn =
return map; return map;
} }
export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap<string, string> | undefined): string { export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap<string, string> | undefined, keyDataType?: string): string {
const raw = displayCellValue(value); const raw = displayCellValue(value);
const key = foreignKeyDisplayValueKey(value); const key = foreignKeyDisplayValueKey(value, keyDataType);
const label = key ? labels?.get(key) : undefined; const label = key ? labels?.get(key) : undefined;
if (label === undefined || label === raw || !label.trim()) return raw; if (label === undefined || label === raw || !label.trim()) return raw;
return `${raw} (${label})`; return `${raw} (${label})`;
+14 -6
View File
@@ -3585,7 +3585,7 @@ fn postgres_indexes_for_relations_query_tiers() -> [&'static str; 2] {
fn postgres_indexes_for_relations_sql() -> &'static str { fn postgres_indexes_for_relations_sql() -> &'static str {
"SELECT t.oid::bigint AS relid, i.relname AS index_name, \ "SELECT t.oid::bigint AS relid, i.relname AS index_name, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \ array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \
ix.indisprimary AS is_primary, \ ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \ am.amname AS index_type, \
@@ -3600,7 +3600,7 @@ fn postgres_indexes_for_relations_sql() -> &'static str {
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \ JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE t.oid = ANY($1::bigint[]) \ WHERE t.oid = ANY($1::bigint[]) \
GROUP BY t.oid, i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \ GROUP BY t.oid, i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
ORDER BY t.oid, i.relname" ORDER BY t.oid, i.relname"
} }
@@ -3617,7 +3617,7 @@ fn postgres_indexes_for_relations_compat_sql() -> &'static str {
AND a.attnum > 0 \ AND a.attnum > 0 \
ORDER BY pos.n \ ORDER BY pos.n \
) AS columns, \ ) AS columns, \
ix.indisunique AS is_unique, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \
ix.indisprimary AS is_primary, \ ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \ am.amname AS index_type, \
@@ -6517,7 +6517,7 @@ async fn execute_query_with_max_rows_inner(
// (schema, table) instead of a batch of oids — see the note there. // (schema, table) instead of a batch of oids — see the note there.
const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \ array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
ix.indisunique AS is_unique, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \
ix.indisprimary AS is_primary, \ ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \ am.amname AS index_type, \
@@ -6533,7 +6533,7 @@ const POSTGRES_INDEXES_SQL: &str = "SELECT i.relname AS index_name, \
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \ JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
WHERE t.oid = (CASE WHEN $1 = '' THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END)::regclass \ WHERE t.oid = (CASE WHEN $1 = '' THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END)::regclass \
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \ GROUP BY i.relname, i.oid, ix.indisunique, ix.indisvalid, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
ORDER BY i.relname"; ORDER BY i.relname";
// Compat-tier sibling of `postgres_indexes_for_relations_compat_sql` (~line // Compat-tier sibling of `postgres_indexes_for_relations_compat_sql` (~line
@@ -6548,7 +6548,7 @@ const POSTGRES_INDEXES_COMPAT_SQL: &str = "SELECT i.relname AS index_name, \
AND a.attnum > 0 \ AND a.attnum > 0 \
ORDER BY pos.n \ ORDER BY pos.n \
) AS columns, \ ) AS columns, \
ix.indisunique AS is_unique, \ (ix.indisunique AND ix.indisvalid) AS is_unique, \
ix.indisprimary AS is_primary, \ ix.indisprimary AS is_primary, \
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
am.amname AS index_type, \ am.amname AS index_type, \
@@ -10739,6 +10739,14 @@ mod tests {
assert!(!POSTGRES_INDEXES_COMPAT_SQL.contains("WITH ORDINALITY")); assert!(!POSTGRES_INDEXES_COMPAT_SQL.contains("WITH ORDINALITY"));
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("generate_series")); assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("generate_series"));
assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("string_to_array(ix.indkey::text, ' ')")); assert!(POSTGRES_INDEXES_COMPAT_SQL.contains("string_to_array(ix.indkey::text, ' ')"));
for sql in [
POSTGRES_INDEXES_SQL,
POSTGRES_INDEXES_COMPAT_SQL,
postgres_indexes_for_relations_sql(),
postgres_indexes_for_relations_compat_sql(),
] {
assert!(sql.contains("ix.indisunique AND ix.indisvalid"));
}
} }
#[test] #[test]
+14 -1
View File
@@ -2688,6 +2688,7 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu
} else { } else {
"CAST(NULL AS NVARCHAR(MAX)) AS filter_definition" "CAST(NULL AS NVARCHAR(MAX)) AS filter_definition"
}; };
let unfiltered_predicate = if include_filter_definition { " AND i.has_filter = 0" } else { "" };
let object_id = sqlserver_object_id_expression(schema, table); let object_id = sqlserver_object_id_expression(schema, table);
format!( format!(
"SELECT i.name, \ "SELECT i.name, \
@@ -2697,7 +2698,7 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu
WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 0 \ WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 0 \
ORDER BY ic2.key_ordinal \ ORDER BY ic2.key_ordinal \
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '') AS columns, \ FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, '') AS columns, \
i.is_unique, i.is_primary_key, i.type_desc, \ CAST(CASE WHEN i.is_unique = 1 AND i.is_disabled = 0 AND i.is_hypothetical = 0{unfiltered_predicate} THEN 1 ELSE 0 END AS bit) AS is_unique, i.is_primary_key, i.type_desc, \
STUFF((SELECT ',' + c3.name \ STUFF((SELECT ',' + c3.name \
FROM sys.index_columns ic3 \ FROM sys.index_columns ic3 \
JOIN sys.columns c3 ON ic3.object_id = c3.object_id AND ic3.column_id = c3.column_id \ JOIN sys.columns c3 ON ic3.object_id = c3.object_id AND ic3.column_id = c3.column_id \
@@ -3973,11 +3974,23 @@ mod tests {
assert!(sql.contains("MS_Description")); assert!(sql.contains("MS_Description"));
} }
#[test]
fn sqlserver_index_metadata_only_reports_usable_unique_indexes() {
let sql = sqlserver_indexes_sql("dbo", "orders");
assert!(sql.contains("i.has_filter = 0"));
for sql in [sql, sqlserver_legacy_indexes_sql("dbo", "orders")] {
assert!(sql.contains("i.is_unique = 1"));
assert!(sql.contains("i.is_disabled = 0"));
assert!(sql.contains("i.is_hypothetical = 0"));
}
}
#[test] #[test]
fn sqlserver_legacy_indexes_sql_omits_filtered_index_metadata() { fn sqlserver_legacy_indexes_sql_omits_filtered_index_metadata() {
let sql = sqlserver_legacy_indexes_sql("dbo", "orders"); let sql = sqlserver_legacy_indexes_sql("dbo", "orders");
assert!(!sql.contains("i.filter_definition")); assert!(!sql.contains("i.filter_definition"));
assert!(!sql.contains("i.has_filter"));
assert!(sql.contains("CAST(NULL AS NVARCHAR(MAX)) AS filter_definition")); assert!(sql.contains("CAST(NULL AS NVARCHAR(MAX)) AS filter_definition"));
assert!(sql.contains("ep.value AS index_comment")); assert!(sql.contains("ep.value AS index_comment"));
} }
+65 -5
View File
@@ -2892,23 +2892,54 @@ mod tests {
oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql, oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql,
oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql,
oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql, oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql,
presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, replace_metadata_runtime, presto_like_information_schema_tables_sql, presto_like_tables_from_query_result,
should_query_oracle_columns_via_sql_first, table_comments_from_query_result, table_name_filter_matches, reference_key_columns_from_indexes, replace_metadata_runtime, should_query_oracle_columns_via_sql_first,
tdengine_table_comment_like_pattern, tdengine_table_comment_sql, tdengine_table_comments_sql, table_comments_from_query_result, table_name_filter_matches, tdengine_table_comment_like_pattern,
uses_mongodb_agent_collection_listing, visible_schema_filter, MetadataErrorAction, MysqlTableListSource, tdengine_table_comment_sql, tdengine_table_comments_sql, uses_mongodb_agent_collection_listing,
TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES, visible_schema_filter, MetadataErrorAction, MysqlTableListSource, TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL,
TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES,
}; };
use super::{list_databases_core, list_tables_core}; use super::{list_databases_core, list_tables_core};
use super::{ use super::{
object_types_include_custom_types, object_types_include_relations, object_types_include_routines, object_types_include_custom_types, object_types_include_relations, object_types_include_routines,
object_types_only_custom_types, supports_custom_type_details, supports_pg_custom_type_objects, object_types_only_custom_types, supports_custom_type_details, supports_pg_custom_type_objects,
}; };
use crate::connection::{AppState, PoolKind}; use crate::connection::{AppState, PoolKind};
use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType}; use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType};
use crate::storage::Storage; use crate::storage::Storage;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
#[test]
fn reference_key_columns_require_effective_unfiltered_single_column_uniqueness() {
let index = |name: &str, columns: &[&str], is_unique: bool, is_primary: bool| db::IndexInfo {
name: name.to_string(),
columns: columns.iter().map(|column| (*column).to_string()).collect(),
is_unique,
is_primary,
filter: None,
index_type: None,
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
};
let mut filtered = index("uq_active_code", &["active_code"], true, false);
filtered.filter = Some("active = true".to_string());
let mut expression = index("uq_lower_email", &["lower(email)"], true, false);
expression.key_is_expression = vec![true];
let indexes = vec![
index("clickhouse_primary", &["event_id"], false, true),
index("uq_code", &["code"], true, false),
index("uq_Code", &["Code"], true, false),
index("uq_tenant_code", &["tenant_id", "code"], true, false),
filtered,
expression,
];
assert_eq!(reference_key_columns_from_indexes(&indexes), vec!["code", "Code"]);
}
fn oracle_current_schema_result(columns: &[&str], rows: Vec<Vec<serde_json::Value>>) -> db::QueryResult { fn oracle_current_schema_result(columns: &[&str], rows: Vec<Vec<serde_json::Value>>) -> db::QueryResult {
db::QueryResult { db::QueryResult {
columns: columns.iter().map(|column| (*column).to_string()).collect(), columns: columns.iter().map(|column| (*column).to_string()).collect(),
@@ -6544,6 +6575,35 @@ pub async fn list_indexes_core(
result result
} }
pub fn reference_key_columns_from_indexes(indexes: &[db::IndexInfo]) -> Vec<String> {
let mut columns = Vec::new();
for index in indexes {
if !index.is_unique
|| index.filter.as_deref().is_some_and(|filter| !filter.trim().is_empty())
|| index.columns.len() != 1
|| index.key_is_expression.first().copied().unwrap_or(false)
{
continue;
}
let column = index.columns[0].trim();
if !column.is_empty() && !columns.iter().any(|existing| existing == column) {
columns.push(column.to_string());
}
}
columns
}
pub async fn list_reference_key_columns_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
) -> Result<Vec<String>, String> {
let indexes = list_indexes_core(state, connection_id, database, schema, table).await?;
Ok(reference_key_columns_from_indexes(&indexes))
}
async fn list_indexes_core_for_session( async fn list_indexes_core_for_session(
state: &AppState, state: &AppState,
connection_id: &str, connection_id: &str,
+1
View File
@@ -423,6 +423,7 @@ async fn main() {
.route("/schema/all-columns", get(routes::schema::get_all_columns)) .route("/schema/all-columns", get(routes::schema::get_all_columns))
.route("/schema/data-types", get(routes::schema::list_data_types)) .route("/schema/data-types", get(routes::schema::list_data_types))
.route("/schema/indexes", get(routes::schema::list_indexes)) .route("/schema/indexes", get(routes::schema::list_indexes))
.route("/schema/reference-key-columns", get(routes::schema::list_reference_key_columns))
.route("/schema/foreign-keys", get(routes::schema::list_foreign_keys)) .route("/schema/foreign-keys", get(routes::schema::list_foreign_keys))
.route("/schema/triggers", get(routes::schema::list_triggers)) .route("/schema/triggers", get(routes::schema::list_triggers))
.route("/schema/constraints", get(routes::schema::list_constraints)) .route("/schema/constraints", get(routes::schema::list_constraints))
+20
View File
@@ -545,6 +545,26 @@ pub async fn list_indexes(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
} }
pub async fn list_reference_key_columns(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<Vec<String>>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let schema = q.schema.as_deref().unwrap_or("");
let table = q.table.as_deref().unwrap_or("");
let catalog = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await;
let indexes = if let Some(catalog) = catalog.as_deref() {
dbx_core::schema::list_doris_catalog_indexes_core(&state.app, &q.connection_id, catalog, database, table)
.await
.map_err(AppError::from)?
} else {
dbx_core::schema::list_indexes_core(&state.app, &q.connection_id, database, schema, table)
.await
.map_err(AppError::from)?
};
Ok(Json(dbx_core::schema::reference_key_columns_from_indexes(&indexes)))
}
pub async fn list_foreign_keys( pub async fn list_foreign_keys(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>, Query(q): Query<SchemaQuery>,
+18
View File
@@ -395,6 +395,24 @@ pub async fn list_indexes(
dbx_core::schema::list_indexes_core(&state, &connection_id, &database, &schema, &table).await dbx_core::schema::list_indexes_core(&state, &connection_id, &database, &schema, &table).await
} }
#[tauri::command]
pub async fn list_reference_key_columns(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
catalog: Option<String>,
) -> Result<Vec<String>, String> {
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
let indexes =
dbx_core::schema::list_doris_catalog_indexes_core(&state, &connection_id, &catalog, &database, &table)
.await?;
return Ok(dbx_core::schema::reference_key_columns_from_indexes(&indexes));
}
dbx_core::schema::list_reference_key_columns_core(&state, &connection_id, &database, &schema, &table).await
}
#[tauri::command] #[tauri::command]
pub async fn list_foreign_keys( pub async fn list_foreign_keys(
state: State<'_, Arc<AppState>>, state: State<'_, Arc<AppState>>,
+1
View File
@@ -1677,6 +1677,7 @@ pub fn run() {
commands::schema::get_all_columns, commands::schema::get_all_columns,
commands::schema::get_sqlserver_column_metadata, commands::schema::get_sqlserver_column_metadata,
commands::schema::list_indexes, commands::schema::list_indexes,
commands::schema::list_reference_key_columns,
commands::schema::list_foreign_keys, commands::schema::list_foreign_keys,
commands::schema::list_triggers, commands::schema::list_triggers,
commands::schema::list_constraints, commands::schema::list_constraints,