diff --git a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java index f821237abf..24791ba596 100644 --- a/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/PostgresLikeAgent.java @@ -544,7 +544,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent { // characters in the text (#6312 review). Map byName = new LinkedHashMap<>(); 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, " + "(a.attname IS NULL) AS is_expression " + "FROM " + profile.catalogRelation("index") + " ix " + @@ -622,7 +622,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent { return unchecked(() -> { List catalogIndexes = new ArrayList<>(); 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 " + "FROM " + profile.catalogRelation("index") + " ix " + "JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " + diff --git a/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java index 1000ded6c6..bb673e9a27 100644 --- a/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java +++ b/agents/common/src/test/java/com/dbx/agent/PostgresLikeAgentTest.java @@ -173,6 +173,7 @@ class PostgresLikeAgentTest { 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("pg_catalog.pg_get_indexdef(ix.indexrelid, k.n, true)"), sql); + assertTrue(sql.contains("ix.indisunique AND ix.indisvalid"), sql); } @Test diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5b46302bd7..84867a7783 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -55,6 +55,7 @@ import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue"; import LightTooltip from "@/components/ui/LightTooltip.vue"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import ErrorBanner from "@/components/ui/ErrorBanner.vue"; @@ -243,13 +244,18 @@ import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssoci import { collectForeignKeyDisplayValues, createForeignKeyDisplayRequestCoordinator, - foreignKeyDisplayConfigMatches, + foreignKeyDisplayConfigIsUsable, foreignKeyDisplayLookupRequestKey, foreignKeyDisplayMapFromResult, formatForeignKeyDisplayValue, + manualReferenceColumnValidation, + manualReferenceKeyColumnIsUnique, + manualReferenceKeyColumns, + reconcileManualReferenceColumn, singleColumnForeignKey, splitForeignKeyDisplayValues, type ForeignKeyDisplayConfig, + type ManualReferenceMetadataStatus, } from "@/lib/dataGrid/dataGridForeignKeyDisplay"; import { useToast } from "@/composables/useToast"; @@ -1092,13 +1098,40 @@ const formatterForeignKeyRefSchema = ref(""); const formatterForeignKeyRefTable = ref(""); const formatterForeignKeyRefColumn = ref(""); const formatterForeignKeyDisplayColumn = ref(""); +const formatterForeignKeyManual = ref(false); +const formatterForeignKeySchemas = ref([]); +const formatterForeignKeyTables = ref([]); const formatterForeignKeyColumns = ref([]); +const formatterForeignKeyReferenceKeyColumns = ref([]); +const formatterForeignKeyReferenceMetadataStatus = ref("loading"); +const formatterForeignKeySchemasLoading = ref(false); +const formatterForeignKeyTablesLoading = ref(false); const formatterForeignKeyColumnsLoading = ref(false); +const formatterForeignKeyTargetError = ref(""); const formatterForeignKeyColumnsError = ref(""); +const formatterForeignKeyReferenceMetadataError = ref(""); +const formatterForeignKeyFilterEnabled = ref(false); +const formatterForeignKeyFilterColumn = ref(""); +const formatterForeignKeyFilterMode = ref("equals"); +const formatterForeignKeyFilterValue = ref(""); +const formatterForeignKeyFilterEndValue = ref(""); let formatterForeignKeyColumnsRequest = 0; -const foreignKeyDisplayLabels = shallowRef(new Map>()); +let formatterForeignKeyTargetRequest = 0; +interface ForeignKeyDisplayLabelState { + keyDataType?: string; + labels: Map; +} + +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()); 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(() => { 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") { return { kind: "foreign-key-display", + referenceMode: formatterForeignKeyManual.value ? "manual" : "foreign-key", refSchema: formatterForeignKeyRefSchema.value || undefined, refTable: formatterForeignKeyRefTable.value, refColumn: formatterForeignKeyRefColumn.value, displayColumn: formatterForeignKeyDisplayColumn.value, + filter: formatterForeignKeyFilterEnabled.value + ? { + column: formatterForeignKeyFilterColumn.value, + mode: formatterForeignKeyFilterMode.value, + value: formatterForeignKeyFilterValue.value, + endValue: formatterForeignKeyFilterEndValue.value, + } + : undefined, }; } return { @@ -1495,6 +1537,24 @@ function currentFormatterDraft(): ColumnFormatterConfig { } 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 ?? { kind: "datetime", unit: "auto" as const, @@ -1527,10 +1587,16 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { formatterCustomName.value = ""; formatterCustomTemplate.value = draft.template; } else if (draft.kind === "foreign-key-display") { + formatterForeignKeyManual.value = draft.referenceMode === "manual"; formatterForeignKeyRefSchema.value = draft.refSchema ?? ""; formatterForeignKeyRefTable.value = draft.refTable; formatterForeignKeyRefColumn.value = draft.refColumn; 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) { const request = ++formatterForeignKeyColumnsRequest; formatterForeignKeyColumns.value = []; + formatterForeignKeyReferenceKeyColumns.value = []; + formatterForeignKeyReferenceMetadataStatus.value = "loading"; formatterForeignKeyColumnsError.value = ""; - const foreignKey = formatterForeignKeyForColumn(columnIndex); - if (!foreignKey || !props.connectionId) return; + formatterForeignKeyReferenceMetadataError.value = ""; + if (!props.connectionId || !formatterForeignKeyRefTable.value) return; formatterForeignKeyColumnsLoading.value = true; try { - const schema = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || props.database || ""; - const columns = await api.getColumns(props.connectionId, props.database || "", schema, foreignKey.ref_table, props.tableMeta?.catalog); + const schema = formatterForeignKeyRefSchema.value || props.tableMeta?.schema || props.schema || props.database || ""; + 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 (columnsResult.status === "rejected") throw columnsResult.reason; + const columns = columnsResult.value; formatterForeignKeyColumns.value = columns; - if (!formatterForeignKeyDisplayColumn.value || !columns.some((column) => column.name === formatterForeignKeyDisplayColumn.value)) { - formatterForeignKeyDisplayColumn.value = columns.find((column) => column.name.toLowerCase() !== foreignKey.ref_column.toLowerCase())?.name ?? columns[0]?.name ?? ""; + if (manual && referenceKeysResult.status === "rejected") { + 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) { if (request === formatterForeignKeyColumnsRequest) formatterForeignKeyColumnsError.value = String(error?.message || error); } 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) { - loadFormatterDraft(savedColumnFormatter(columnIndex)); + const savedFormatter = savedColumnFormatter(columnIndex); + loadFormatterDraft(savedFormatter); formatterOpenColumn.value = columnIndex; await fetchForeignKeys(); 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 || ""; formatterForeignKeyRefTable.value = foreignKey.ref_table; formatterForeignKeyRefColumn.value = foreignKey.ref_column; @@ -1586,6 +1757,7 @@ function openCompactColumnFormatter(columnIndex: number) { function closeColumnFormatter() { formatterForeignKeyColumnsRequest += 1; + formatterForeignKeyTargetRequest += 1; formatterOpenColumn.value = null; } @@ -1635,18 +1807,29 @@ function clearColumnFormatter(columnIndex: number) { } 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) { formatterKind.value = value; if (value !== "foreign-key-display") return; const foreignKey = formatterForeignKeyForColumn(columnIndex); - if (!foreignKey) return; - formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || ""; - formatterForeignKeyRefTable.value = foreignKey.ref_table; - formatterForeignKeyRefColumn.value = foreignKey.ref_column; - void loadFormatterForeignKeyColumns(columnIndex); + formatterForeignKeyManual.value = !foreignKey; + if (foreignKey) { + formatterForeignKeyRefSchema.value = foreignKey.ref_schema || props.tableMeta?.schema || props.schema || ""; + formatterForeignKeyRefTable.value = foreignKey.ref_table; + 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) { @@ -1702,7 +1885,7 @@ function formatterPreviewRows(columnIndex: number) { return { index: index + 1, 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 { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; 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; } const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex); @@ -10352,7 +10535,7 @@ function savedForeignKeyDisplayConfig(columnIndex: number): ForeignKeyDisplayCon const formatter = savedColumnFormatter(columnIndex); if (formatter?.kind !== "foreign-key-display") return undefined; const foreignKey = formatterForeignKeyForColumn(columnIndex); - return foreignKey && foreignKeyDisplayConfigMatches(formatter, foreignKey, props.tableMeta?.schema || props.schema) ? formatter : undefined; + return foreignKeyDisplayConfigIsUsable(formatter, foreignKey, props.tableMeta?.schema || props.schema) ? formatter : undefined; } async function loadForeignKeyDisplayLabels() { @@ -10360,22 +10543,37 @@ async function loadForeignKeyDisplayLabels() { foreignKeyDisplayLabels.value = new Map(); clearCellFormatCache(); 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] : [])); await Promise.all( configuredColumns.map(async (columnIndex) => { const config = savedForeignKeyDisplayConfig(columnIndex); if (!config) return; - const values = collectForeignKeyDisplayValues(props.result.rows, columnIndex); - if (!values.length) return; const schema = config.refSchema || props.tableMeta?.schema || props.schema || props.database || ""; try { const columns = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["columns", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () => api.getColumns(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog), ); if (!columns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; - const refColumnInfo = columns.find((column) => column.name.toLowerCase() === config.refColumn.toLowerCase()); + const 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(); for (const batch of splitForeignKeyDisplayValues(values)) { if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; @@ -10387,6 +10585,22 @@ async function loadForeignKeyDisplayLabels() { values: batch, }); 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({ databaseType: resolvedDatabaseType.value, driverProfile: connectionStore.getConfig(props.connectionId!)?.driver_profile, @@ -10397,7 +10611,7 @@ async function loadForeignKeyDisplayLabels() { tableName: config.refTable, columns: [config.refColumn, config.displayColumn], includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, - whereInput, + whereInput: lookupWhereInput, limit: batch.length, }); if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; @@ -10409,6 +10623,8 @@ async function loadForeignKeyDisplayLabels() { table: config.refTable, refColumn: config.refColumn, displayColumn: config.displayColumn, + keyDataType, + filter: config.filter, values: batch, }); const result = await foreignKeyDisplayRequests.request(requestGeneration, requestKey, () => @@ -10419,11 +10635,11 @@ async function loadForeignKeyDisplayLabels() { }), ); 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; const next = new Map(foreignKeyDisplayLabels.value); - next.set(columnIndex, labels); + next.set(columnIndex, { keyDataType, labels }); foreignKeyDisplayLabels.value = next; } catch (error: any) { if (!foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; @@ -11914,8 +12130,15 @@ function currentGridContextMenuItems(): ContextMenuItem[] { - -
+ +
{{ t("grid.columnFormatterFor", { @@ -11927,7 +12150,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { {{ t("grid.columnFormatterHint") }}
-
+
{{ t("grid.formatterType") }} @@ -11940,7 +12163,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { {{ t("grid.formatterDatetime") }} {{ t("grid.formatterJsonPath") }} {{ t("grid.formatterMask") }} - {{ t("grid.formatterForeignKeyDisplay") }} + {{ t("grid.formatterForeignKeyDisplay") }} {{ t("grid.formatterCustomTemplate") }} @@ -12021,27 +12244,142 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
-
-
{{ t("grid.formatterForeignKeyTarget") }}
-
- {{ formatterForeignKeyRefSchema ? `${formatterForeignKeyRefSchema}.` : "" }}{{ formatterForeignKeyRefTable }}.{{ formatterForeignKeyRefColumn }} + + +
{{ t("grid.formatterForeignKeyHint") }}
@@ -12107,7 +12445,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
-
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 6d8f5ecada..15c2175dcf 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1717,9 +1717,25 @@ export default { formatterMask: "Mask text", formatterForeignKeyDisplay: "Foreign key label", 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", 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", formatterTimestampUnit: "Timestamp unit", formatterUnitAuto: "Auto", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 81a1383632..ea0355445b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1601,9 +1601,25 @@ export default withEnglishFallback({ formatterMask: "Enmascarar texto", formatterForeignKeyDisplay: "Etiqueta de clave externa", 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", 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", formatterTimestampUnit: "Unidad de marca de tiempo", formatterUnitAuto: "Automático", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7699ea4d98..8256e2417a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1599,9 +1599,25 @@ export default withEnglishFallback({ formatterMask: "Maschera testo", formatterForeignKeyDisplay: "Etichetta chiave esterna", 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", 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", formatterTimestampUnit: "Unità timestamp", formatterUnitAuto: "Auto", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e7eddd20bf..943e1295e9 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1617,9 +1617,25 @@ export default withEnglishFallback({ formatterMask: "テキストをマスク", formatterForeignKeyDisplay: "外部キーラベル", formatterForeignKeyTarget: "参照キー", + formatterForeignKeyManualHint: "この列には単一列の外部キーがありません。参照テーブルと対応列を手動で指定してください。", + formatterForeignKeySchema: "スキーマ", + formatterForeignKeySchemaPlaceholder: "スキーマを選択", + formatterForeignKeySearchSchema: "スキーマを検索...", + formatterForeignKeyNoSchemas: "利用可能なスキーマがありません", + formatterForeignKeyTable: "参照テーブル", + formatterForeignKeyTablePlaceholder: "参照テーブルを選択", + formatterForeignKeySearchTable: "参照テーブルを検索...", + formatterForeignKeyNoTables: "利用可能な参照テーブルがありません", + formatterForeignKeyReferenceColumn: "対応列", + formatterForeignKeyReferenceColumnPlaceholder: "現在の値に対応する列を選択", + formatterForeignKeyReferenceMetadataUnavailable: "対応列の一意性を確認できませんでした:{error}", + formatterForeignKeyReferenceInvalid: "この列は存在しないか、有効な単一列の一意キーではありません。別の列を選択してください。", + formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な単一列の一意キーがありません。", formatterForeignKeyDisplayColumn: "表示列", formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択", - formatterForeignKeyHint: "現在のページのキーに対応するラベルのみ読み込みます。編集、フィルター、並べ替え、コピー、エクスポートでは元の値を使用します。", + formatterForeignKeyFilter: "参照行を絞り込む", + formatterForeignKeyFilterColumnPlaceholder: "フィルター列を選択", + formatterForeignKeyHint: "現在のページの対応キーに対するラベルのみ読み込みます。編集、フィルター、並べ替え、コピー、エクスポートでは元の値を使用します。", formatterCustomTemplate: "カスタムテンプレート", formatterTimestampUnit: "タイムスタンプ単位", formatterUnitAuto: "自動", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b54deed993..87a9e57607 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1601,9 +1601,25 @@ export default withEnglishFallback({ formatterMask: "Mascarar texto", formatterForeignKeyDisplay: "Rótulo de chave estrangeira", 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", 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", formatterTimestampUnit: "Unidade do timestamp", formatterUnitAuto: "Automático", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4880a54faf..52c2af116f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1642,9 +1642,25 @@ export default withEnglishFallback({ formatterMask: "文本脱敏", formatterForeignKeyDisplay: "外键显示值", formatterForeignKeyTarget: "引用键", + formatterForeignKeyManualHint: "当前字段没有单列外键。请手动指定引用表和关联字段。", + formatterForeignKeySchema: "Schema", + formatterForeignKeySchemaPlaceholder: "选择 Schema", + formatterForeignKeySearchSchema: "搜索 Schema...", + formatterForeignKeyNoSchemas: "没有可用的 Schema", + formatterForeignKeyTable: "引用表", + formatterForeignKeyTablePlaceholder: "选择引用表", + formatterForeignKeySearchTable: "搜索引用表...", + formatterForeignKeyNoTables: "没有可用的引用表", + formatterForeignKeyReferenceColumn: "关联字段", + formatterForeignKeyReferenceColumnPlaceholder: "选择与当前字段匹配的字段", + formatterForeignKeyReferenceMetadataUnavailable: "无法验证关联字段唯一性:{error}", + formatterForeignKeyReferenceInvalid: "该字段不存在,或不是有效的单列唯一键。请重新选择。", + formatterForeignKeyReferenceUnavailable: "目标表没有可用的单列唯一键。", formatterForeignKeyDisplayColumn: "显示字段", formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段", - formatterForeignKeyHint: "仅加载当前页外键对应的显示值;编辑、筛选、排序、复制和导出仍使用原始值。", + formatterForeignKeyFilter: "限定引用数据", + formatterForeignKeyFilterColumnPlaceholder: "选择筛选字段", + formatterForeignKeyHint: "仅加载当前页关联键对应的显示值;编辑、筛选、排序、复制和导出仍使用原始值。", formatterCustomTemplate: "自定义模板", formatterTimestampUnit: "时间戳单位", formatterUnitAuto: "自动", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3dac8434ff..18e0e8dbb1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1600,9 +1600,25 @@ export default withEnglishFallback({ formatterMask: "文字遮罩", formatterForeignKeyDisplay: "外鍵顯示值", formatterForeignKeyTarget: "引用鍵", + formatterForeignKeyManualHint: "目前欄位沒有單欄外鍵。請手動指定引用資料表和關聯欄位。", + formatterForeignKeySchema: "Schema", + formatterForeignKeySchemaPlaceholder: "選擇 Schema", + formatterForeignKeySearchSchema: "搜尋 Schema...", + formatterForeignKeyNoSchemas: "沒有可用的 Schema", + formatterForeignKeyTable: "引用資料表", + formatterForeignKeyTablePlaceholder: "選擇引用資料表", + formatterForeignKeySearchTable: "搜尋引用資料表...", + formatterForeignKeyNoTables: "沒有可用的引用資料表", + formatterForeignKeyReferenceColumn: "關聯欄位", + formatterForeignKeyReferenceColumnPlaceholder: "選擇與目前欄位匹配的欄位", + formatterForeignKeyReferenceMetadataUnavailable: "無法驗證關聯欄位的唯一性:{error}", + formatterForeignKeyReferenceInvalid: "此欄位不存在,或不是有效的單欄唯一鍵。請重新選擇。", + formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的單欄唯一鍵。", formatterForeignKeyDisplayColumn: "顯示欄位", formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位", - formatterForeignKeyHint: "僅載入目前頁面外鍵對應的顯示值;編輯、篩選、排序、複製與匯出仍使用原始值。", + formatterForeignKeyFilter: "限定引用資料", + formatterForeignKeyFilterColumnPlaceholder: "選擇篩選欄位", + formatterForeignKeyHint: "僅載入目前頁面關聯鍵對應的顯示值;編輯、篩選、排序、複製與匯出仍使用原始值。", formatterCustomTemplate: "自訂範本", formatterTimestampUnit: "時間戳單位", formatterUnitAuto: "自動", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts index 7461277661..1e9225bfac 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts @@ -1,5 +1,42 @@ 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", () => { it("accepts the format grammar shared by the frontend and backend", () => { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts index 6bda535133..7a7531402e 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts @@ -2,15 +2,20 @@ import { describe, expect, it } from "vitest"; import { collectForeignKeyDisplayValues, createForeignKeyDisplayRequestCoordinator, + foreignKeyDisplayConfigIsUsable, foreignKeyDisplayConfigMatches, foreignKeyDisplayLookupRequestKey, foreignKeyDisplayMapFromResult, formatForeignKeyDisplayValue, + manualReferenceColumnValidation, + manualReferenceKeyColumnIsUnique, + manualReferenceKeyColumns, + reconcileManualReferenceColumn, singleColumnForeignKey, splitForeignKeyDisplayValues, } from "@/lib/dataGrid/dataGridForeignKeyDisplay"; import { buildColumnForeignKeyMap } from "@/lib/dataGrid/dataGridForeignKeyNavigation"; -import type { QueryResult } from "@/types/database"; +import type { ColumnInfo, QueryResult } from "@/types/database"; function deferred() { let resolve!: (value: T | PromiseLike) => 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); }); + 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", () => { const rows = [[100], [100], ["100"], [null], [101], [{ id: 1 }]] as QueryResult["rows"]; const values = collectForeignKeyDisplayValues(rows, 0); @@ -48,6 +93,13 @@ describe("dataGridForeignKeyDisplay", () => { 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", () => { const rows = Array.from({ length: 2500 }, (_, index) => [index]) as QueryResult["rows"]; const values = collectForeignKeyDisplayValues(rows, 0); @@ -70,17 +122,47 @@ describe("dataGridForeignKeyDisplay", () => { ], } as QueryResult; 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(102, labels)).toBe("102"); expect(formatForeignKeyDisplayValue(103, labels)).toBe("103"); expect(formatForeignKeyDisplayValue(null, labels)).toBe("NULL"); + expect(mismatchedCaseLabels.size).toBe(0); expect(formatForeignKeyDisplayValue("same", new Map([["string\u0000same", "same"]]))).toBe("same"); expect(formatForeignKeyDisplayValue(100, codeLabels)).toBe("100 (U100)"); expect(foreignKeyDisplayMapFromResult(result, "missing", "code")).toEqual(new Map()); }); + it("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 () => { const coordinator = createForeignKeyDisplayRequestCoordinator({ concurrency: 2 }); const gate = deferred(); @@ -94,6 +176,13 @@ describe("dataGridForeignKeyDisplay", () => { const firstKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: [100, "100"] }); const reorderedKey = foreignKeyDisplayLookupRequestKey({ ...requestScope, values: ["100", 100] }); 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 duplicate = coordinator.request(generation, reorderedKey, task); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 530a21c8ac..9cea127c25 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -172,6 +172,7 @@ export const getAllColumns = forward("getAllColumns"); export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const listDataTypes = forward("listDataTypes"); export const listIndexes = forward("listIndexes"); +export const listReferenceKeyColumns = forward("listReferenceKeyColumns"); export const listForeignKeys = forward("listForeignKeys"); export const listTriggers = forward("listTriggers"); export const listConstraints = forward("listConstraints"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index e3ff1ba1e9..5ea9922623 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -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 })}`); } +export async function listReferenceKeyColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + 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 { return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 79ceb6e360..20601bf5a2 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { + 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 { return invoke("list_foreign_keys", { connectionId, diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index 885709a282..b777338a14 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -1,4 +1,5 @@ import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql"; import dayjs, { Dayjs } from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; import utc from "dayjs/plugin/utc"; @@ -60,6 +61,13 @@ export interface CustomColumnFormatterConfig { template: string; } +export interface ForeignKeyDisplayFilterConfig { + column: string; + mode: DataGridContextFilterMode; + value?: string; + endValue?: string; +} + interface IntlTimeZoneSupport { supportedValuesOf?: (key: "timeZone") => string[]; } @@ -105,7 +113,15 @@ export type ColumnFormatterConfig = | { kind: "iotdb-timestamp"; precision: IoTDBTimestampPrecision; timezone: string } | { kind: "json-path"; path: string } | { 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-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.refColumn !== "string" || !config.refColumn.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 { 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, refTable: config.refTable.trim(), refColumn: config.refColumn.trim(), displayColumn: config.displayColumn.trim(), + ...(filter ? { filter } : {}), }; } @@ -170,6 +190,26 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig return undefined; } +const FOREIGN_KEY_DISPLAY_FILTER_MODES = new Set(["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; + 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 { if (!value || typeof value !== "object") return undefined; const config = value as Record; diff --git a/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts b/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts index d84c4bf26e..44485741db 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts @@ -1,7 +1,8 @@ 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 { ForeignKeyInfo, QueryResult } from "@/types/database"; +import type { ColumnInfo, ForeignKeyInfo, QueryResult } from "@/types/database"; export type ForeignKeyDisplayConfig = Extract; @@ -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(); } -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 (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)}`; } -export function foreignKeyDisplayLookupRequestKey(options: { connectionId: string; database?: string; catalog?: string; schema?: string; table: string; refColumn: string; displayColumn: string; values: readonly CellValue[] }): string { - const valueKeys = options.values.map((value) => foreignKeyDisplayValueKey(value) ?? "").sort(); - return JSON.stringify(["lookup", options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.table, options.refColumn, options.displayColumn, valueKeys]); +export function 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, options.keyDataType) ?? "").sort(); + 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 seen = new Set(); for (const row of rows) { const value = row[columnIndex] as CellValue | undefined; - const key = foreignKeyDisplayValueKey(value); + const key = foreignKeyDisplayValueKey(value, keyDataType); if (!key || seen.has(key)) continue; seen.add(key); values.push(value!); @@ -191,13 +241,13 @@ export function splitForeignKeyDisplayValues(values: readonly CellValue[], batch return batches; } -export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1]): Map { +export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = result.columns[0], displayColumn = result.columns[1], keyDataType?: string): Map { const map = new Map(); - const keyIndex = result.columns.findIndex((column) => column.toLowerCase() === keyColumn?.toLowerCase()); - const displayIndex = result.columns.findIndex((column) => column.toLowerCase() === displayColumn?.toLowerCase()); + const keyIndex = result.columns.findIndex((column) => column === keyColumn); + const displayIndex = result.columns.findIndex((column) => column === displayColumn); if (keyIndex < 0 || displayIndex < 0) return map; 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; if (!key || labelValue === null || labelValue === undefined || map.has(key)) continue; map.set(key, displayCellValue(labelValue)); @@ -205,9 +255,9 @@ export function foreignKeyDisplayMapFromResult(result: QueryResult, keyColumn = return map; } -export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap | undefined): string { +export function formatForeignKeyDisplayValue(value: CellValue, labels: ReadonlyMap | undefined, keyDataType?: string): string { const raw = displayCellValue(value); - const key = foreignKeyDisplayValueKey(value); + const key = foreignKeyDisplayValueKey(value, keyDataType); const label = key ? labels?.get(key) : undefined; if (label === undefined || label === raw || !label.trim()) return raw; return `${raw} (${label})`; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 55aff4853e..4bbaf08550 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -3585,7 +3585,7 @@ fn postgres_indexes_for_relations_query_tiers() -> [&'static str; 2] { fn postgres_indexes_for_relations_sql() -> &'static str { "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, \ - ix.indisunique AS is_unique, \ + (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ 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 \ 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[]) \ - 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" } @@ -3617,7 +3617,7 @@ fn postgres_indexes_for_relations_compat_sql() -> &'static str { AND a.attnum > 0 \ ORDER BY pos.n \ ) AS columns, \ - ix.indisunique AS is_unique, \ + (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ 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. 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, \ - ix.indisunique AS is_unique, \ + (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ 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 \ 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 \ - 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"; // 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 \ ORDER BY pos.n \ ) AS columns, \ - ix.indisunique AS is_unique, \ + (ix.indisunique AND ix.indisvalid) AS is_unique, \ ix.indisprimary AS is_primary, \ pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \ 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("generate_series")); 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] diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index c228169e07..c0f922da96 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -2688,6 +2688,7 @@ fn sqlserver_indexes_sql_with_filter_definition(schema: &str, table: &str, inclu } else { "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); format!( "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 \ ORDER BY ic2.key_ordinal \ 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 \ FROM sys.index_columns ic3 \ 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")); } + #[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] fn sqlserver_legacy_indexes_sql_omits_filtered_index_metadata() { let sql = sqlserver_legacy_indexes_sql("dbo", "orders"); 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("ep.value AS index_comment")); } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index dc2e477a02..4f80b3e323 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -2892,23 +2892,54 @@ mod tests { 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_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, - should_query_oracle_columns_via_sql_first, table_comments_from_query_result, table_name_filter_matches, - tdengine_table_comment_like_pattern, tdengine_table_comment_sql, tdengine_table_comments_sql, - uses_mongodb_agent_collection_listing, visible_schema_filter, MetadataErrorAction, MysqlTableListSource, - TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES, + presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, + reference_key_columns_from_indexes, replace_metadata_runtime, should_query_oracle_columns_via_sql_first, + table_comments_from_query_result, table_name_filter_matches, tdengine_table_comment_like_pattern, + tdengine_table_comment_sql, tdengine_table_comments_sql, uses_mongodb_agent_collection_listing, + 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::{ 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, }; + use crate::connection::{AppState, PoolKind}; use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType}; use crate::storage::Storage; use std::collections::HashMap; 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>) -> db::QueryResult { db::QueryResult { columns: columns.iter().map(|column| (*column).to_string()).collect(), @@ -6544,6 +6575,35 @@ pub async fn list_indexes_core( result } +pub fn reference_key_columns_from_indexes(indexes: &[db::IndexInfo]) -> Vec { + 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, 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( state: &AppState, connection_id: &str, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index fcbe72835f..2d3d13ba78 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -423,6 +423,7 @@ async fn main() { .route("/schema/all-columns", get(routes::schema::get_all_columns)) .route("/schema/data-types", get(routes::schema::list_data_types)) .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/triggers", get(routes::schema::list_triggers)) .route("/schema/constraints", get(routes::schema::list_constraints)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 53d4f55b56..9e30fb220f 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -545,6 +545,26 @@ pub async fn list_indexes( 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>, + Query(q): Query, +) -> Result>, 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( State(state): State>, Query(q): Query, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 6cf9162937..91394f948a 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -395,6 +395,24 @@ pub async fn list_indexes( 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>, + connection_id: String, + database: String, + schema: String, + table: String, + catalog: Option, +) -> Result, 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] pub async fn list_foreign_keys( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 57b6e319da..b8f57df2b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1677,6 +1677,7 @@ pub fn run() { commands::schema::get_all_columns, commands::schema::get_sqlserver_column_metadata, commands::schema::list_indexes, + commands::schema::list_reference_key_columns, commands::schema::list_foreign_keys, commands::schema::list_triggers, commands::schema::list_constraints,