From eb0994e98072562c6028d06a8ec8785ba2e6970d Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 27 Aug 2026 16:00:00 +0800 Subject: [PATCH] feat(grid): support composite unique reference keys --- apps/desktop/src/components/grid/DataGrid.vue | 43 +++++++---- apps/desktop/src/i18n/locales/en.ts | 4 +- apps/desktop/src/i18n/locales/es.ts | 4 +- apps/desktop/src/i18n/locales/it.ts | 4 +- apps/desktop/src/i18n/locales/ja.ts | 4 +- apps/desktop/src/i18n/locales/pt-BR.ts | 4 +- apps/desktop/src/i18n/locales/zh-CN.ts | 4 +- apps/desktop/src/i18n/locales/zh-TW.ts | 4 +- .../dataGridForeignKeyDisplay.spec.ts | 44 ++++++++++- apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 5 ++ apps/desktop/src/lib/backend/tauri.ts | 11 +++ .../lib/dataGrid/dataGridForeignKeyDisplay.ts | 26 +++++-- apps/desktop/src/types/database.ts | 4 + crates/dbx-core/src/schema.rs | 73 +++++++++++++++---- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/schema.rs | 20 +++++ src-tauri/src/commands/schema.rs | 18 +++++ src-tauri/src/lib.rs | 1 + 19 files changed, 219 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7866449896..7bb3f3990b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -77,7 +77,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; import EnumCellEditor from "@/components/grid/EnumCellEditor.vue"; import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue"; import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue"; -import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database"; +import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind"; import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation"; @@ -180,6 +180,7 @@ import { resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, + type ForeignKeyDisplayFilterConfig, DateTimePatterns, } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; @@ -1163,7 +1164,7 @@ const formatterForeignKeyManual = ref(false); const formatterForeignKeySchemas = ref([]); const formatterForeignKeyTables = ref([]); const formatterForeignKeyColumns = ref([]); -const formatterForeignKeyReferenceKeyColumns = ref([]); +const formatterForeignKeyReferenceKeys = ref([]); const formatterForeignKeyReferenceMetadataStatus = ref("loading"); const formatterForeignKeySchemasLoading = ref(false); const formatterForeignKeyTablesLoading = ref(false); @@ -1183,8 +1184,18 @@ interface ForeignKeyDisplayLabelState { 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 formatterForeignKeyReferenceFilter = computed(() => + formatterForeignKeyFilterEnabled.value + ? { + column: formatterForeignKeyFilterColumn.value, + mode: formatterForeignKeyFilterMode.value, + value: formatterForeignKeyFilterValue.value, + endValue: formatterForeignKeyFilterEndValue.value, + } + : undefined, +); +const formatterForeignKeyReferenceColumns = computed(() => (formatterForeignKeyManual.value ? manualReferenceKeyColumns(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeys.value, formatterForeignKeyReferenceFilter.value) : formatterForeignKeyColumns.value)); +const formatterForeignKeyReferenceValidation = computed(() => manualReferenceColumnValidation(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeys.value, formatterForeignKeyRefColumn.value, formatterForeignKeyReferenceMetadataStatus.value, formatterForeignKeyReferenceFilter.value)); const foreignKeyDisplayLabels = shallowRef(new Map()); const foreignKeyDisplayRequests = createForeignKeyDisplayRequestCoordinator(); @@ -1606,7 +1617,7 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { formatterForeignKeySchemas.value = []; formatterForeignKeyTables.value = []; formatterForeignKeyColumns.value = []; - formatterForeignKeyReferenceKeyColumns.value = []; + formatterForeignKeyReferenceKeys.value = []; formatterForeignKeyReferenceMetadataStatus.value = "loading"; formatterForeignKeyTargetError.value = ""; formatterForeignKeyColumnsError.value = ""; @@ -1668,7 +1679,7 @@ function formatterForeignKeyForColumn(columnIndex: number): ForeignKeyInfo | und async function loadFormatterForeignKeyColumns(columnIndex: number) { const request = ++formatterForeignKeyColumnsRequest; formatterForeignKeyColumns.value = []; - formatterForeignKeyReferenceKeyColumns.value = []; + formatterForeignKeyReferenceKeys.value = []; formatterForeignKeyReferenceMetadataStatus.value = "loading"; formatterForeignKeyColumnsError.value = ""; formatterForeignKeyReferenceMetadataError.value = ""; @@ -1679,7 +1690,7 @@ async function loadFormatterForeignKeyColumns(columnIndex: number) { 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[]), + manual ? api.listReferenceKeys(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog) : Promise.resolve([] as ReferenceKeyInfo[]), ]); if (request !== formatterForeignKeyColumnsRequest || formatterOpenColumn.value !== columnIndex) return; if (columnsResult.status === "rejected") throw columnsResult.reason; @@ -1689,10 +1700,10 @@ async function loadFormatterForeignKeyColumns(columnIndex: number) { formatterForeignKeyReferenceMetadataStatus.value = "unavailable"; formatterForeignKeyReferenceMetadataError.value = String(referenceKeysResult.reason?.message || referenceKeysResult.reason); } else { - formatterForeignKeyReferenceKeyColumns.value = referenceKeysResult.status === "fulfilled" ? referenceKeysResult.value : []; + formatterForeignKeyReferenceKeys.value = referenceKeysResult.status === "fulfilled" ? referenceKeysResult.value : []; formatterForeignKeyReferenceMetadataStatus.value = "available"; } - const referenceColumns = manualReferenceKeyColumns(columns, formatterForeignKeyReferenceKeyColumns.value); + const referenceColumns = manualReferenceKeyColumns(columns, formatterForeignKeyReferenceKeys.value, formatterForeignKeyReferenceFilter.value); const foreignKey = formatterForeignKeyForColumn(columnIndex); if (manual) { formatterForeignKeyRefColumn.value = reconcileManualReferenceColumn(formatterForeignKeyRefColumn.value, referenceColumns, formatterForeignKeyReferenceMetadataStatus.value); @@ -1763,7 +1774,7 @@ function selectFormatterForeignKeySchema(value: string, columnIndex: number) { formatterForeignKeyDisplayColumn.value = ""; formatterForeignKeyFilterColumn.value = ""; formatterForeignKeyColumns.value = []; - formatterForeignKeyReferenceKeyColumns.value = []; + formatterForeignKeyReferenceKeys.value = []; formatterForeignKeyReferenceMetadataStatus.value = "loading"; formatterForeignKeyReferenceMetadataError.value = ""; void loadFormatterManualReferenceTables(columnIndex); @@ -1775,7 +1786,7 @@ function selectFormatterForeignKeyTable(value: string, columnIndex: number) { formatterForeignKeyRefColumn.value = ""; formatterForeignKeyDisplayColumn.value = ""; formatterForeignKeyFilterColumn.value = ""; - formatterForeignKeyReferenceKeyColumns.value = []; + formatterForeignKeyReferenceKeys.value = []; formatterForeignKeyReferenceMetadataStatus.value = "loading"; formatterForeignKeyReferenceMetadataError.value = ""; void loadFormatterForeignKeyColumns(columnIndex); @@ -10925,12 +10936,12 @@ async function loadForeignKeyDisplayLabels() { 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), + const referenceKeys = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["reference-keys", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () => + api.listReferenceKeys(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]", { + if (!referenceKeys || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return; + if (!manualReferenceKeyColumnIsUnique(columns, referenceKeys, config.refColumn, config.filter)) { + appendDebugLog("warn", "[DBX][DataGrid:foreign-key-display:unsafe-manual-reference]", { column: props.result.columns[columnIndex], reference: `${schema}.${config.refTable}.${config.refColumn}`, }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b8eeef538e..073a48a1c2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1756,8 +1756,8 @@ export default { 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.", + formatterForeignKeyReferenceInvalid: "This column is missing or is not guaranteed unique by a unique key and the current equality filter. Select another column.", + formatterForeignKeyReferenceUnavailable: "The target table has no usable unique key. For a composite unique key, fix the other column with an equality filter.", formatterForeignKeyDisplayColumn: "Display column", formatterForeignKeyDisplayColumnPlaceholder: "Select a display column", formatterForeignKeyFilter: "Limit referenced rows", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 608f89e701..345aed9932 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1636,8 +1636,8 @@ export default withEnglishFallback({ 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.", + formatterForeignKeyReferenceInvalid: "La columna no existe o una clave única y el filtro de igualdad actual no garantizan que sea única. Seleccione otra columna.", + formatterForeignKeyReferenceUnavailable: "La tabla de destino no tiene ninguna clave única utilizable. Para una clave única compuesta, fije la otra columna con un filtro de igualdad.", formatterForeignKeyDisplayColumn: "Columna de visualización", formatterForeignKeyDisplayColumnPlaceholder: "Seleccionar una columna", formatterForeignKeyFilter: "Limitar filas referenciadas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 90ef4697fc..e4735b8e39 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1634,8 +1634,8 @@ export default withEnglishFallback({ 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.", + formatterForeignKeyReferenceInvalid: "La colonna non esiste o non è garantita come univoca da una chiave univoca e dal filtro di uguaglianza corrente. Seleziona un'altra colonna.", + formatterForeignKeyReferenceUnavailable: "La tabella di destinazione non ha chiavi univoche utilizzabili. Per una chiave univoca composta, fissa l'altra colonna con un filtro di uguaglianza.", formatterForeignKeyDisplayColumn: "Colonna visualizzata", formatterForeignKeyDisplayColumnPlaceholder: "Seleziona una colonna", formatterForeignKeyFilter: "Limita le righe referenziate", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 08cbe1d8a6..ed5b77f0a2 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1652,8 +1652,8 @@ export default withEnglishFallback({ formatterForeignKeyReferenceColumn: "対応列", formatterForeignKeyReferenceColumnPlaceholder: "現在の値に対応する列を選択", formatterForeignKeyReferenceMetadataUnavailable: "対応列の一意性を確認できませんでした:{error}", - formatterForeignKeyReferenceInvalid: "この列は存在しないか、有効な単一列の一意キーではありません。別の列を選択してください。", - formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な単一列の一意キーがありません。", + formatterForeignKeyReferenceInvalid: "この列は存在しないか、一意キーと現在の等価条件によって一意性を保証できません。別の列を選択してください。", + formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な一意キーがありません。複合一意キーでは、等価条件で別の列を固定してください。", formatterForeignKeyDisplayColumn: "表示列", formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択", formatterForeignKeyFilter: "参照行を絞り込む", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 75381cf85e..c05d59b4e2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1636,8 +1636,8 @@ export default withEnglishFallback({ 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.", + formatterForeignKeyReferenceInvalid: "A coluna não existe ou não tem unicidade garantida por uma chave exclusiva e pelo filtro de igualdade atual. Selecione outra coluna.", + formatterForeignKeyReferenceUnavailable: "A tabela de destino não tem uma chave exclusiva utilizável. Para uma chave exclusiva composta, fixe a outra coluna com um filtro de igualdade.", formatterForeignKeyDisplayColumn: "Coluna de exibição", formatterForeignKeyDisplayColumnPlaceholder: "Selecione uma coluna", formatterForeignKeyFilter: "Limitar linhas referenciadas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ab9b2f291f..b97cc804e8 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1681,8 +1681,8 @@ export default withEnglishFallback({ formatterForeignKeyReferenceColumn: "关联字段", formatterForeignKeyReferenceColumnPlaceholder: "选择与当前字段匹配的字段", formatterForeignKeyReferenceMetadataUnavailable: "无法验证关联字段唯一性:{error}", - formatterForeignKeyReferenceInvalid: "该字段不存在,或不是有效的单列唯一键。请重新选择。", - formatterForeignKeyReferenceUnavailable: "目标表没有可用的单列唯一键。", + formatterForeignKeyReferenceInvalid: "该字段不存在,或无法由唯一键和当前等值条件保证唯一。请重新选择。", + formatterForeignKeyReferenceUnavailable: "目标表没有可用的唯一键。联合唯一键需使用等值条件固定另一个字段。", formatterForeignKeyDisplayColumn: "显示字段", formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段", formatterForeignKeyFilter: "限定引用数据", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 41ca875015..9e327c31c4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1635,8 +1635,8 @@ export default withEnglishFallback({ formatterForeignKeyReferenceColumn: "關聯欄位", formatterForeignKeyReferenceColumnPlaceholder: "選擇與目前欄位匹配的欄位", formatterForeignKeyReferenceMetadataUnavailable: "無法驗證關聯欄位的唯一性:{error}", - formatterForeignKeyReferenceInvalid: "此欄位不存在,或不是有效的單欄唯一鍵。請重新選擇。", - formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的單欄唯一鍵。", + formatterForeignKeyReferenceInvalid: "此欄位不存在,或無法由唯一鍵和目前的等值條件保證唯一。請重新選擇。", + formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的唯一鍵。複合唯一鍵需使用等值條件固定另一個欄位。", formatterForeignKeyDisplayColumn: "顯示欄位", formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位", formatterForeignKeyFilter: "限定引用資料", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts index 7a7531402e..a9e410197a 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyDisplay.spec.ts @@ -10,6 +10,7 @@ import { manualReferenceColumnValidation, manualReferenceKeyColumnIsUnique, manualReferenceKeyColumns, + manualReferenceKeySupportsColumn, reconcileManualReferenceColumn, singleColumnForeignKey, splitForeignKeyDisplayValues, @@ -69,12 +70,48 @@ describe("dataGridForeignKeyDisplay", () => { { 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); + const referenceKeys = [{ columns: ["code"] }]; + expect(manualReferenceKeyColumns(columns, referenceKeys).map((column) => column.name)).toEqual(["code"]); + expect(manualReferenceKeyColumnIsUnique(columns, referenceKeys, "code")).toBe(true); + expect(manualReferenceKeyColumnIsUnique(columns, referenceKeys, "Code")).toBe(false); expect(manualReferenceKeyColumnIsUnique(columns, [], "tenant_id")).toBe(false); }); + it("supports one column of a two-column unique key only when the other column is fixed", () => { + const columns = ["dict_key", "dict_value", "label"].map((name) => ({ name, data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null })) as ColumnInfo[]; + const referenceKeys = [{ columns: ["dict_key", "dict_value"] }]; + const statusFilter = { column: "dict_key", mode: "equals" as const, value: "status" }; + + expect(manualReferenceKeyColumns(columns, referenceKeys, statusFilter).map((column) => column.name)).toEqual(["dict_value"]); + expect(manualReferenceKeyColumnIsUnique(columns, referenceKeys, "dict_value", statusFilter)).toBe(true); + expect(manualReferenceKeyColumnIsUnique(columns, referenceKeys, "dict_key", statusFilter)).toBe(false); + }); + + it("requires an exact non-empty equals filter for composite reference keys", () => { + const referenceKeys = [{ columns: ["DictKey", "dict_value"] }]; + + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "DictKey", mode: "equals", value: "status" })).toBe(true); + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "dictkey", mode: "equals", value: "status" })).toBe(false); + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "DictKey", mode: "equals", value: " " })).toBe(false); + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "DictKey", mode: "like", value: "status" })).toBe(false); + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "DictKey", mode: "in", value: "status,gender" })).toBe(false); + expect(manualReferenceKeySupportsColumn(referenceKeys, "dict_value", { column: "DictKey", mode: "between", value: "a", endValue: "z" })).toBe(false); + }); + + it("does not infer uniqueness from a partially fixed three-column key", () => { + const threeColumnKey = [{ columns: ["tenant_id", "dict_key", "dict_value"] }]; + const filter = { column: "dict_key", mode: "equals" as const, value: "status" }; + + expect(manualReferenceKeySupportsColumn(threeColumnKey, "dict_value", filter)).toBe(false); + }); + + it("accepts a column when any current key proves it unique", () => { + const referenceKeys = [{ columns: ["tenant_id", "code"] }, { columns: ["external_id"] }]; + + expect(manualReferenceKeySupportsColumn(referenceKeys, "code", { column: "tenant_id", mode: "equals", value: "acme" })).toBe(true); + expect(manualReferenceKeySupportsColumn(referenceKeys, "external_id")).toBe(true); + }); + 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[]; @@ -84,6 +121,7 @@ describe("dataGridForeignKeyDisplay", () => { expect(reconcileManualReferenceColumn("", columns, "unavailable")).toBe(""); expect(manualReferenceColumnValidation(columns, [], "legacy_code", "available")).toBe("invalid"); expect(manualReferenceColumnValidation(columns, [], "legacy_code", "unavailable")).toBe("unavailable"); + expect(manualReferenceColumnValidation(columns, [{ columns: ["tenant_id", "legacy_code"] }], "legacy_code", "available", { column: "tenant_id", mode: "equals", value: "" })).toBe("invalid"); }); it("deduplicates current-page keys with type-safe identities and bounded batches", () => { diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index e2091db075..f4d3322172 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -175,6 +175,7 @@ export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const listDataTypes = forward("listDataTypes"); export const listIndexes = forward("listIndexes"); export const listReferenceKeyColumns = forward("listReferenceKeyColumns"); +export const listReferenceKeys = forward("listReferenceKeys"); 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 3f5023dbcc..a0a19ebead 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -21,6 +21,7 @@ import type { ColumnInfo, SqlServerColumnMetadata, IndexInfo, + ReferenceKeyInfo, ForeignKeyInfo, TriggerInfo, ConstraintInfo, @@ -906,6 +907,10 @@ export async function listReferenceKeyColumns(connectionId: string, database: st return get(`/api/schema/reference-key-columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } +export async function listReferenceKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/reference-keys?${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 e4d51945d6..8c61076999 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -37,6 +37,7 @@ import type { ColumnInfo, SqlServerColumnMetadata, IndexInfo, + ReferenceKeyInfo, ForeignKeyInfo, TriggerInfo, ConstraintInfo, @@ -1807,6 +1808,16 @@ export async function listReferenceKeyColumns(connectionId: string, database: st }); } +export async function listReferenceKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_reference_keys", { + 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/dataGridForeignKeyDisplay.ts b/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts index 44485741db..6626a437c6 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridForeignKeyDisplay.ts @@ -2,7 +2,7 @@ import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import type { ColumnFormatterConfig, ForeignKeyDisplayFilterConfig } from "@/lib/dataGrid/columnFormatter"; import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; import type { ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation"; -import type { ColumnInfo, ForeignKeyInfo, QueryResult } from "@/types/database"; +import type { ColumnInfo, ForeignKeyInfo, QueryResult, ReferenceKeyInfo } from "@/types/database"; export type ForeignKeyDisplayConfig = Extract; @@ -169,19 +169,29 @@ export function foreignKeyDisplayConfigIsUsable(config: ForeignKeyDisplayConfig, 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 manualReferenceKeySupportsColumn(referenceKeys: readonly ReferenceKeyInfo[], columnName: string, filter?: ForeignKeyDisplayFilterConfig): boolean { + if (!columnName) return false; + return referenceKeys.some((key) => { + if (!key.columns.includes(columnName)) return false; + if (key.columns.length === 1) return true; + if (key.columns.length !== 2 || filter?.mode !== "equals" || !filter.value?.trim()) return false; + const otherColumn = key.columns.find((column) => column !== columnName); + return !!otherColumn && filter.column === otherColumn; + }); } -export function manualReferenceKeyColumnIsUnique(columns: readonly ColumnInfo[], referenceKeyColumnNames: readonly string[], columnName: string): boolean { - return !!columnName && manualReferenceKeyColumns(columns, referenceKeyColumnNames).some((column) => column.name === columnName); +export function manualReferenceKeyColumns(columns: readonly ColumnInfo[], referenceKeys: readonly ReferenceKeyInfo[], filter?: ForeignKeyDisplayFilterConfig): ColumnInfo[] { + return columns.filter((column) => manualReferenceKeySupportsColumn(referenceKeys, column.name, filter)); } -export function manualReferenceColumnValidation(columns: readonly ColumnInfo[], referenceKeyColumnNames: readonly string[], columnName: string, metadataStatus: ManualReferenceMetadataStatus): ManualReferenceColumnValidation { +export function manualReferenceKeyColumnIsUnique(columns: readonly ColumnInfo[], referenceKeys: readonly ReferenceKeyInfo[], columnName: string, filter?: ForeignKeyDisplayFilterConfig): boolean { + return !!columns.some((column) => column.name === columnName) && manualReferenceKeySupportsColumn(referenceKeys, columnName, filter); +} + +export function manualReferenceColumnValidation(columns: readonly ColumnInfo[], referenceKeys: readonly ReferenceKeyInfo[], columnName: string, metadataStatus: ManualReferenceMetadataStatus, filter?: ForeignKeyDisplayFilterConfig): ManualReferenceColumnValidation { if (metadataStatus === "loading") return "pending"; if (metadataStatus === "unavailable") return "unavailable"; - return manualReferenceKeyColumnIsUnique(columns, referenceKeyColumnNames, columnName) ? "valid" : "invalid"; + return manualReferenceKeyColumnIsUnique(columns, referenceKeys, columnName, filter) ? "valid" : "invalid"; } export function reconcileManualReferenceColumn(currentColumn: string, referenceColumns: readonly ColumnInfo[], metadataStatus: ManualReferenceMetadataStatus): string { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 30203bee51..e9369a30f9 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -523,6 +523,10 @@ export interface IndexInfo { key_is_expression?: boolean[] | null; } +export interface ReferenceKeyInfo { + columns: string[]; +} + export interface ForeignKeyInfo { name: string; column: string; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index b97f8b4198..052567b045 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -3059,12 +3059,12 @@ mod tests { 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, - 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, OracleObjectRef, OracleSynonymResolver, - TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, ORACLE_SYNONYM_MAX_DEPTH, TDENGINE_COMMENT_SEARCH_TIMEOUT, - TDENGINE_LIKE_PATTERN_MAX_BYTES, + reference_key_columns_from_indexes, reference_keys_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, + OracleObjectRef, OracleSynonymResolver, ReferenceKeyInfo, TableNameFilter, ORACLE_CURRENT_SCHEMA_SQL, + ORACLE_SYNONYM_MAX_DEPTH, TDENGINE_COMMENT_SEARCH_TIMEOUT, TDENGINE_LIKE_PATTERN_MAX_BYTES, }; use super::{list_databases_core, list_tables_core}; use super::{ @@ -3079,7 +3079,7 @@ mod tests { use std::time::Duration; #[test] - fn reference_key_columns_require_effective_unfiltered_single_column_uniqueness() { + fn reference_keys_require_effective_unfiltered_plain_unique_columns() { 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(), @@ -3095,15 +3095,29 @@ mod tests { 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 mut composite_expression = index("uq_tenant_lower_code", &["tenant_id", "lower(code)"], true, false); + composite_expression.key_is_expression = vec![false, 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), + index("uq_tenant_code_duplicate", &["tenant_id", "code"], true, false), + index("uq_empty", &[""], true, false), + index("uq_repeated", &["tenant_id", "tenant_id"], true, false), filtered, expression, + composite_expression, ]; + assert_eq!( + reference_keys_from_indexes(&indexes), + vec![ + ReferenceKeyInfo { columns: vec!["code".to_string()] }, + ReferenceKeyInfo { columns: vec!["Code".to_string()] }, + ReferenceKeyInfo { columns: vec!["tenant_id".to_string(), "code".to_string()] }, + ] + ); assert_eq!(reference_key_columns_from_indexes(&indexes), vec!["code", "Code"]); } @@ -6782,22 +6796,51 @@ pub async fn list_indexes_core( result } -pub fn reference_key_columns_from_indexes(indexes: &[db::IndexInfo]) -> Vec { - let mut columns = Vec::new(); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferenceKeyInfo { + pub columns: Vec, +} + +pub fn reference_keys_from_indexes(indexes: &[db::IndexInfo]) -> Vec { + let mut keys = 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) + || index.columns.is_empty() + || index.key_is_expression.iter().any(|is_expression| *is_expression) { continue; } - let column = index.columns[0].trim(); - if !column.is_empty() && !columns.iter().any(|existing| existing == column) { - columns.push(column.to_string()); + let columns = index.columns.iter().map(|column| column.trim().to_string()).collect::>(); + let mut seen = HashSet::new(); + if columns.iter().any(|column| column.is_empty() || !seen.insert(column.as_str())) { + continue; + } + let key = ReferenceKeyInfo { columns }; + if !keys.contains(&key) { + keys.push(key); } } - columns + keys +} + +pub fn reference_key_columns_from_indexes(indexes: &[db::IndexInfo]) -> Vec { + reference_keys_from_indexes(indexes) + .into_iter() + .filter_map(|key| (key.columns.len() == 1).then(|| key.columns[0].clone())) + .collect() +} + +pub async fn list_reference_keys_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_keys_from_indexes(&indexes)) } pub async fn list_reference_key_columns_core( diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index e989b637a0..848d284a42 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -425,6 +425,7 @@ async fn main() { .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/reference-keys", get(routes::schema::list_reference_keys)) .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 d13924af2f..139aee0bbe 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -590,6 +590,26 @@ pub async fn list_reference_key_columns( Ok(Json(dbx_core::schema::reference_key_columns_from_indexes(&indexes))) } +pub async fn list_reference_keys( + 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_keys_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 239f9a2dee..22d6979600 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -434,6 +434,24 @@ pub async fn list_reference_key_columns( dbx_core::schema::list_reference_key_columns_core(&state, &connection_id, &database, &schema, &table).await } +#[tauri::command] +pub async fn list_reference_keys( + 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_keys_from_indexes(&indexes)); + } + dbx_core::schema::list_reference_keys_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 f0dec6fff2..f5be6b79fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1925,6 +1925,7 @@ pub fn run() { commands::schema::get_sqlserver_column_metadata, commands::schema::list_indexes, commands::schema::list_reference_key_columns, + commands::schema::list_reference_keys, commands::schema::list_foreign_keys, commands::schema::list_triggers, commands::schema::list_constraints,