feat(grid): navigate foreign key cells to referenced records
This commit is contained in:
parent
8f3fd0ebfb
commit
ac6b53365b
|
|
@ -5,6 +5,7 @@ import {
|
|||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Upload,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
|
|
@ -138,7 +139,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
|
|||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
|
||||
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
|
||||
|
|
@ -177,8 +178,10 @@ import {
|
|||
dataGridSelectedSortMenuValue,
|
||||
type DataGridColumnSortState,
|
||||
} from "@/lib/dataGrid/dataGridContextMenu";
|
||||
import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssociationCells, foreignKeyMetadataRequestCurrent, foreignKeyNavigationTarget, foreignKeySourceColumnName, foreignKeyTableIdentity, type ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
|
||||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
|
||||
|
|
@ -250,6 +253,12 @@ const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMu
|
|||
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
|
||||
const { isDark, themePalette } = useTheme();
|
||||
const { toast } = useToast();
|
||||
// 外键单元格跳转复用导航入口;对话框引用传 stub(同 AiAssistant 模式)
|
||||
const { openTableTarget } = useNavigationTargets({
|
||||
showFieldLineageDialog: ref(false),
|
||||
showDatabaseSearchDialog: ref(false),
|
||||
showDiagramDialog: ref(false),
|
||||
});
|
||||
const { highlight } = useSqlHighlighter();
|
||||
const binaryCellDownloadMenuItems = computed(() =>
|
||||
BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
|
||||
|
|
@ -5077,15 +5086,16 @@ const canvasDetailButtonCell = computed(() => {
|
|||
const visibleLeft = Math.max(rowNumberWidth.value, rect.left);
|
||||
const visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
|
||||
const minWidth = canQuickDownload ? 46 : 24;
|
||||
const foreignKey = canvasCellForeignKey(target.rowIndex, target.col);
|
||||
const minWidth = canvasDataGridActionOverlayWidth(canQuickDownload, !!foreignKey) + 2;
|
||||
if (rect.top < 0 || rect.top > viewportHeight - 1 || visibleRight - visibleLeft < minWidth) return null;
|
||||
return { rowIndex: target.rowIndex, visibleColIdx, actualColIdx: target.col, rect, canQuickDownload };
|
||||
return { rowIndex: target.rowIndex, visibleColIdx, actualColIdx: target.col, rect, canQuickDownload, foreignKey };
|
||||
});
|
||||
|
||||
const canvasDetailButtonStyle = computed(() => {
|
||||
const cell = canvasDetailButtonCell.value;
|
||||
if (!cell) return {};
|
||||
const actionWidth = cell.canQuickDownload ? 44 : 22;
|
||||
const actionWidth = canvasDataGridActionOverlayWidth(cell.canQuickDownload, !!cell.foreignKey);
|
||||
const edgeGap = 6;
|
||||
return {
|
||||
left: `${Math.max(rowNumberWidth.value, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`,
|
||||
|
|
@ -5099,7 +5109,7 @@ const canvasRightAlignedActionCell = computed(() => {
|
|||
return {
|
||||
rowIndex: cell.rowIndex,
|
||||
visibleColIdx: cell.visibleColIdx,
|
||||
reservedWidth: canvasDataGridActionReservedWidth(cell.canQuickDownload),
|
||||
reservedWidth: canvasDataGridActionReservedWidth(cell.canQuickDownload, !!cell.foreignKey),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -7202,6 +7212,16 @@ const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
|||
const foreignKeysLoaded = ref(false);
|
||||
const foreignKeysLoading = ref(false);
|
||||
const foreignKeysError = ref("");
|
||||
const currentForeignKeyTableIdentity = computed(() =>
|
||||
foreignKeyTableIdentity({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
catalog: props.tableMeta?.catalog,
|
||||
schema: props.tableMeta?.schema,
|
||||
tableName: props.tableMeta?.tableName,
|
||||
}),
|
||||
);
|
||||
let foreignKeysRequestGeneration = 0;
|
||||
const triggers = ref<TriggerInfo[]>([]);
|
||||
const triggersLoaded = ref(false);
|
||||
const triggersLoading = ref(false);
|
||||
|
|
@ -7394,16 +7414,26 @@ async function reloadIndexes() {
|
|||
}
|
||||
|
||||
async function fetchForeignKeys() {
|
||||
if (!props.connectionId || !props.tableMeta || foreignKeysLoaded.value || foreignKeysLoading.value) return;
|
||||
const requestIdentity = currentForeignKeyTableIdentity.value;
|
||||
if (!props.connectionId || !props.tableMeta || !requestIdentity || foreignKeysLoaded.value || foreignKeysLoading.value) return;
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database || "";
|
||||
const schema = props.tableMeta.schema || props.database || "";
|
||||
const tableName = props.tableMeta.tableName;
|
||||
const catalog = props.tableMeta.catalog;
|
||||
const requestGeneration = ++foreignKeysRequestGeneration;
|
||||
foreignKeysLoading.value = true;
|
||||
foreignKeysError.value = "";
|
||||
try {
|
||||
foreignKeys.value = await api.listForeignKeys(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, props.tableMeta.catalog);
|
||||
const nextForeignKeys = await api.listForeignKeys(connectionId, database, schema, tableName, catalog);
|
||||
if (!foreignKeyMetadataRequestCurrent({ requestGeneration, currentGeneration: foreignKeysRequestGeneration, requestIdentity, currentIdentity: currentForeignKeyTableIdentity.value })) return;
|
||||
foreignKeys.value = nextForeignKeys;
|
||||
foreignKeysLoaded.value = true;
|
||||
} catch (e: any) {
|
||||
if (!foreignKeyMetadataRequestCurrent({ requestGeneration, currentGeneration: foreignKeysRequestGeneration, requestIdentity, currentIdentity: currentForeignKeyTableIdentity.value })) return;
|
||||
foreignKeysError.value = String(e?.message || e);
|
||||
} finally {
|
||||
foreignKeysLoading.value = false;
|
||||
if (foreignKeyMetadataRequestCurrent({ requestGeneration, currentGeneration: foreignKeysRequestGeneration, requestIdentity, currentIdentity: currentForeignKeyTableIdentity.value })) foreignKeysLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7430,7 +7460,9 @@ watch(
|
|||
indexesError.value = "";
|
||||
foreignKeys.value = [];
|
||||
foreignKeysLoaded.value = false;
|
||||
foreignKeysLoading.value = false;
|
||||
foreignKeysError.value = "";
|
||||
foreignKeysRequestGeneration += 1;
|
||||
triggers.value = [];
|
||||
triggersLoaded.value = false;
|
||||
triggersError.value = "";
|
||||
|
|
@ -7438,6 +7470,114 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
// ---- 外键单元格跳转 ----
|
||||
const columnForeignKeyMap = computed(() => buildColumnForeignKeyMap(foreignKeys.value));
|
||||
const foreignKeyNavigationEnabled = computed(() => !!props.connectionId && !!props.tableMeta?.tableName && tableMetadataCapabilities.value.foreignKeys);
|
||||
|
||||
function cellForeignKeyAssociation(actualColIdx: number): ForeignKeyAssociation | null {
|
||||
if (!foreignKeyNavigationEnabled.value) return null;
|
||||
const columnName = foreignKeySourceColumnName({
|
||||
context: props.context,
|
||||
resultColumns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
columnIndex: actualColIdx,
|
||||
});
|
||||
if (!columnName) return null;
|
||||
return columnForeignKeyMap.value.get(columnName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function canvasCellForeignKey(rowIndex: number, actualColIdx: number): ForeignKeyInfo | null {
|
||||
const association = cellForeignKeyAssociation(actualColIdx);
|
||||
if (!association) return null;
|
||||
const item = displayItems.value[rowIndex];
|
||||
if (!item) return null;
|
||||
const cells = foreignKeyAssociationCells({
|
||||
association,
|
||||
context: props.context,
|
||||
resultColumns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
row: item.data,
|
||||
});
|
||||
return cells ? association.foreignKey : null;
|
||||
}
|
||||
|
||||
// 外键跳转按钮需要 FK 元数据:表身份就绪即后台加载(fetchForeignKeys 自带去重,
|
||||
// 上方 reset watch 先清旧表状态)
|
||||
watch(
|
||||
() => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName],
|
||||
() => {
|
||||
if (foreignKeyNavigationEnabled.value) void fetchForeignKeys();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function navigateToForeignKeyCell(rowIndex: number, actualColIdx: number) {
|
||||
const association = cellForeignKeyAssociation(actualColIdx);
|
||||
const item = displayItems.value[rowIndex];
|
||||
if (!association || !item || !props.connectionId) return;
|
||||
const cells = foreignKeyAssociationCells({
|
||||
association,
|
||||
context: props.context,
|
||||
resultColumns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
row: item.data,
|
||||
});
|
||||
if (!cells) return;
|
||||
try {
|
||||
const conditions = await Promise.all(
|
||||
cells.map(({ foreignKey, value }) =>
|
||||
buildColumnValueFilterCondition({
|
||||
databaseType: resolvedDatabaseType.value,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
columnName: foreignKey.ref_column,
|
||||
columnInfo: props.tableMeta?.columns.find((column) => column.name.toLowerCase() === foreignKey.column.toLowerCase()),
|
||||
rawValue: String(value),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const condition = combineForeignKeyConditions(conditions);
|
||||
if (!condition) return;
|
||||
await openTableTarget(
|
||||
foreignKeyNavigationTarget({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database || props.tableMeta?.database || "",
|
||||
currentSchema: props.tableMeta?.schema || props.schema,
|
||||
fk: association.foreignKey,
|
||||
whereInput: condition,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
toast(String(e?.message || e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function contextForeignKeyMenuItem(): ContextMenuItem | null {
|
||||
const cell = contextCell.value;
|
||||
if (!cell || cell.col < 0) return null;
|
||||
const association = cellForeignKeyAssociation(cell.col);
|
||||
const item = contextRowItem.value;
|
||||
if (
|
||||
!association ||
|
||||
!item ||
|
||||
!foreignKeyAssociationCells({
|
||||
association,
|
||||
context: props.context,
|
||||
resultColumns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
row: item.data,
|
||||
})
|
||||
)
|
||||
return null;
|
||||
const fk = association.foreignKey;
|
||||
return {
|
||||
label: t("grid.foreignKeyNavigate", { table: fk.ref_table }),
|
||||
icon: ArrowUpRight,
|
||||
action: () => {
|
||||
void navigateToForeignKeyCell(cell.rowIndex, cell.col);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (showTableInfo.value && props.tableMeta && props.connectionId) {
|
||||
selectTableInfoTab(activeTableInfoTab.value);
|
||||
}
|
||||
|
|
@ -7950,6 +8090,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
icons: { cellDetails: Maximize2, columnDetails: TableProperties, rowDetails: ListTree, setNull: X, bulkEdit: Pencil, transpose: Rows3 },
|
||||
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
|
||||
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
|
||||
foreignKeyItem: contextForeignKeyMenuItem(),
|
||||
copySubmenu: copySubmenu(),
|
||||
clearSelectionItem: { label: t("grid.clearSelection"), action: clearCellSelection, icon: SquareDashed },
|
||||
generateSubmenu: {
|
||||
|
|
@ -8842,6 +8983,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
<button
|
||||
v-if="canvasDetailButtonCell.foreignKey"
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
||||
:title="t('grid.foreignKeyNavigate', { table: canvasDetailButtonCell.foreignKey.ref_table })"
|
||||
@mousedown.stop
|
||||
@click.stop="navigateToForeignKeyCell(canvasDetailButtonCell.rowIndex, canvasDetailButtonCell.actualColIdx)"
|
||||
>
|
||||
<ArrowUpRight class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
||||
:title="t('grid.cellDetails')"
|
||||
|
|
|
|||
|
|
@ -1399,6 +1399,7 @@ export default {
|
|||
tableInfoColumns: "Columns",
|
||||
tableInfoIndexes: "Indexes",
|
||||
tableInfoForeignKeys: "Foreign Keys",
|
||||
foreignKeyNavigate: "Go to {table}",
|
||||
tableInfoTriggers: "Triggers",
|
||||
tableInfoNullable: "Nullable",
|
||||
tableInfoEmpty: "No metadata",
|
||||
|
|
|
|||
|
|
@ -1292,6 +1292,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "Columnas",
|
||||
tableInfoIndexes: "Índices",
|
||||
tableInfoForeignKeys: "Claves foráneas",
|
||||
foreignKeyNavigate: "Ir a {table}",
|
||||
tableInfoTriggers: "Disparadores",
|
||||
tableInfoNullable: "Nulable",
|
||||
tableInfoEmpty: "Sin metadatos",
|
||||
|
|
|
|||
|
|
@ -1281,6 +1281,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "Colonne",
|
||||
tableInfoIndexes: "Indici",
|
||||
tableInfoForeignKeys: "Chiavi Esterne",
|
||||
foreignKeyNavigate: "Vai a {table}",
|
||||
tableInfoTriggers: "Trigger",
|
||||
tableInfoNullable: "Nullabile",
|
||||
tableInfoEmpty: "Nessun metadato",
|
||||
|
|
|
|||
|
|
@ -1278,6 +1278,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "列",
|
||||
tableInfoIndexes: "インデックス",
|
||||
tableInfoForeignKeys: "外部キー",
|
||||
foreignKeyNavigate: "{table} へ移動",
|
||||
tableInfoTriggers: "トリガー",
|
||||
tableInfoNullable: "NULL許容",
|
||||
tableInfoEmpty: "メタデータなし",
|
||||
|
|
|
|||
|
|
@ -1378,6 +1378,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "컬럼",
|
||||
tableInfoIndexes: "인덱스",
|
||||
tableInfoForeignKeys: "외래 키",
|
||||
foreignKeyNavigate: "{table}(으)로 이동",
|
||||
tableInfoTriggers: "트리거",
|
||||
tableInfoNullable: "NULL 허용",
|
||||
tableInfoEmpty: "메타데이터 없음",
|
||||
|
|
|
|||
|
|
@ -1283,6 +1283,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "Colunas",
|
||||
tableInfoIndexes: "Índices",
|
||||
tableInfoForeignKeys: "Chaves Estrangeiras",
|
||||
foreignKeyNavigate: "Ir para {table}",
|
||||
tableInfoTriggers: "Gatilhos",
|
||||
tableInfoNullable: "Permite Nulo",
|
||||
tableInfoEmpty: "Sem metadados",
|
||||
|
|
|
|||
|
|
@ -1400,6 +1400,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "字段",
|
||||
tableInfoIndexes: "索引",
|
||||
tableInfoForeignKeys: "外键",
|
||||
foreignKeyNavigate: "跳转到 {table}",
|
||||
tableInfoTriggers: "触发器",
|
||||
tableInfoNullable: "可空",
|
||||
tableInfoEmpty: "暂无元数据",
|
||||
|
|
|
|||
|
|
@ -1282,6 +1282,7 @@ export default withEnglishFallback({
|
|||
tableInfoColumns: "欄位",
|
||||
tableInfoIndexes: "索引",
|
||||
tableInfoForeignKeys: "外鍵",
|
||||
foreignKeyNavigate: "跳轉到 {table}",
|
||||
tableInfoTriggers: "觸發器",
|
||||
tableInfoNullable: "可為空",
|
||||
tableInfoEmpty: "無",
|
||||
|
|
|
|||
159
apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyNavigation.spec.ts
vendored
Normal file
159
apps/desktop/src/lib/__tests__/dataGrid/dataGridForeignKeyNavigation.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssociationCells, foreignKeyCellNavigable, foreignKeyMetadataRequestCurrent, foreignKeyNavigationTarget, foreignKeySourceColumnName, foreignKeyTableIdentity } from "@/lib/dataGrid/dataGridForeignKeyNavigation";
|
||||
import type { ForeignKeyInfo } from "@/types/database";
|
||||
|
||||
function fk(overrides: Partial<ForeignKeyInfo> = {}): ForeignKeyInfo {
|
||||
return {
|
||||
name: "fk_orders_customer",
|
||||
column: "customer_id",
|
||||
ref_schema: "public",
|
||||
ref_table: "customers",
|
||||
ref_column: "id",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildColumnForeignKeyMap", () => {
|
||||
it("indexa por nombre de columna en minúsculas", () => {
|
||||
const map = buildColumnForeignKeyMap([fk({ column: "Customer_ID" })]);
|
||||
expect(map.get("customer_id")?.foreignKey.ref_table).toBe("customers");
|
||||
expect(map.has("Customer_ID")).toBe(false);
|
||||
});
|
||||
|
||||
it("ante columnas duplicadas gana la primera entrada", () => {
|
||||
const map = buildColumnForeignKeyMap([fk({ name: "fk_a", ref_table: "customers" }), fk({ name: "fk_b", ref_table: "accounts" })]);
|
||||
expect(map.get("customer_id")?.foreignKey.name).toBe("fk_a");
|
||||
});
|
||||
|
||||
it("agrupa todas las parejas de columnas de una FK compuesta", () => {
|
||||
const map = buildColumnForeignKeyMap([fk({ name: "fk_comp", column: "order_id", ref_table: "order_items", ref_column: "order_id" }), fk({ name: "fk_comp", column: "line_no", ref_table: "order_items", ref_column: "line_no" })]);
|
||||
expect(map.size).toBe(2);
|
||||
expect(map.get("order_id")).toBe(map.get("line_no"));
|
||||
expect(map.get("order_id")?.columnPairs.map((pair) => [pair.column, pair.ref_column])).toEqual([
|
||||
["order_id", "order_id"],
|
||||
["line_no", "line_no"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignora entradas incompletas", () => {
|
||||
const map = buildColumnForeignKeyMap([fk({ column: "" }), fk({ column: "a", ref_table: "" }), fk({ column: "b", ref_column: "" })]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreignKeySourceColumnName", () => {
|
||||
it("en resultados SQL solo usa el binding de columna física", () => {
|
||||
expect(foreignKeySourceColumnName({ context: "results", resultColumns: ["customer"], sourceColumns: ["customer_id"], columnIndex: 0 })).toBe("customer_id");
|
||||
expect(foreignKeySourceColumnName({ context: "results", resultColumns: ["customer_id"], columnIndex: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("en datos de tabla usa el nombre visible si no hay binding separado", () => {
|
||||
expect(foreignKeySourceColumnName({ context: "table-data", resultColumns: ["customer_id"], columnIndex: 0 })).toBe("customer_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreignKeyAssociationCells", () => {
|
||||
const association = buildColumnForeignKeyMap([fk({ name: "fk_comp", column: "order_id", ref_table: "order_items", ref_column: "order_id" }), fk({ name: "fk_comp", column: "line_no", ref_table: "order_items", ref_column: "line_no" })]).get("order_id")!;
|
||||
|
||||
it("resuelve todas las columnas físicas de una FK compuesta en resultados con alias", () => {
|
||||
const cells = foreignKeyAssociationCells({
|
||||
association,
|
||||
context: "results",
|
||||
resultColumns: ["order", "line"],
|
||||
sourceColumns: ["order_id", "line_no"],
|
||||
row: [42, 7],
|
||||
});
|
||||
expect(cells?.map((cell) => [cell.foreignKey.ref_column, cell.columnIndex, cell.value])).toEqual([
|
||||
["order_id", 0, 42],
|
||||
["line_no", 1, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rechaza la navegación si falta un binding físico", () => {
|
||||
expect(
|
||||
foreignKeyAssociationCells({
|
||||
association,
|
||||
context: "results",
|
||||
resultColumns: ["order", "line_no"],
|
||||
sourceColumns: ["order_id", undefined],
|
||||
row: [42, 7],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rechaza la navegación si cualquier valor compuesto es nulo", () => {
|
||||
expect(
|
||||
foreignKeyAssociationCells({
|
||||
association,
|
||||
context: "table-data",
|
||||
resultColumns: ["order_id", "line_no"],
|
||||
row: [42, null],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineForeignKeyConditions", () => {
|
||||
it("combina todas las parejas de una FK compuesta con AND", () => {
|
||||
expect(combineForeignKeyConditions(['"order_id" = 42', '"line_no" = 7'])).toBe('("order_id" = 42) AND ("line_no" = 7)');
|
||||
});
|
||||
|
||||
it("no construye un filtro parcial", () => {
|
||||
expect(combineForeignKeyConditions(['"order_id" = 42', undefined])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreign key metadata request guard", () => {
|
||||
it("incluye toda la identidad de tabla", () => {
|
||||
const first = foreignKeyTableIdentity({ connectionId: "c1", database: "db", catalog: "cat", schema: "sales", tableName: "orders" });
|
||||
const reusedTab = foreignKeyTableIdentity({ connectionId: "c1", database: "db", catalog: "cat", schema: "sales", tableName: "invoices" });
|
||||
expect(first).not.toBe(reusedTab);
|
||||
});
|
||||
|
||||
it("ignora respuestas de otra tabla o generación", () => {
|
||||
const orders = foreignKeyTableIdentity({ connectionId: "c1", database: "db", schema: "sales", tableName: "orders" })!;
|
||||
const invoices = foreignKeyTableIdentity({ connectionId: "c1", database: "db", schema: "sales", tableName: "invoices" })!;
|
||||
expect(foreignKeyMetadataRequestCurrent({ requestGeneration: 3, currentGeneration: 3, requestIdentity: orders, currentIdentity: orders })).toBe(true);
|
||||
expect(foreignKeyMetadataRequestCurrent({ requestGeneration: 3, currentGeneration: 4, requestIdentity: orders, currentIdentity: orders })).toBe(false);
|
||||
expect(foreignKeyMetadataRequestCurrent({ requestGeneration: 3, currentGeneration: 3, requestIdentity: orders, currentIdentity: invoices })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreignKeyCellNavigable", () => {
|
||||
it("null y undefined no son navegables", () => {
|
||||
expect(foreignKeyCellNavigable(null)).toBe(false);
|
||||
expect(foreignKeyCellNavigable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("0, cadena vacía y false son valores FK legítimos", () => {
|
||||
expect(foreignKeyCellNavigable(0)).toBe(true);
|
||||
expect(foreignKeyCellNavigable("")).toBe(true);
|
||||
expect(foreignKeyCellNavigable(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreignKeyNavigationTarget", () => {
|
||||
it("usa ref_schema cuando está presente", () => {
|
||||
const target = foreignKeyNavigationTarget({ connectionId: "c1", database: "db", currentSchema: "app", fk: fk({ ref_schema: "sales" }) });
|
||||
expect(target.schema).toBe("sales");
|
||||
expect(target.tableName).toBe("customers");
|
||||
expect(target.columnName).toBe("id");
|
||||
expect(target.connectionId).toBe("c1");
|
||||
expect(target.database).toBe("db");
|
||||
});
|
||||
|
||||
it("sin ref_schema cae al schema actual", () => {
|
||||
const target = foreignKeyNavigationTarget({ connectionId: "c1", database: "db", currentSchema: "app", fk: fk({ ref_schema: null }) });
|
||||
expect(target.schema).toBe("app");
|
||||
});
|
||||
|
||||
it("sin ref_schema ni schema actual queda undefined", () => {
|
||||
const target = foreignKeyNavigationTarget({ connectionId: "c1", database: "db", fk: fk({ ref_schema: undefined }) });
|
||||
expect(target.schema).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propaga whereInput", () => {
|
||||
const target = foreignKeyNavigationTarget({ connectionId: "c1", database: "db", fk: fk(), whereInput: '"id" = 7' });
|
||||
expect(target.whereInput).toBe('"id" = 7');
|
||||
});
|
||||
});
|
||||
|
|
@ -143,8 +143,13 @@ export function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWi
|
|||
return result;
|
||||
}
|
||||
|
||||
export function canvasDataGridActionReservedWidth(canQuickDownload: boolean): number {
|
||||
return (canQuickDownload ? 44 : 22) + 6;
|
||||
export function canvasDataGridActionReservedWidth(canQuickDownload: boolean, canNavigateForeignKey = false): number {
|
||||
return canvasDataGridActionOverlayWidth(canQuickDownload, canNavigateForeignKey) + 6;
|
||||
}
|
||||
|
||||
/** 悬浮按钮组宽度:每个按钮 20px + 2px 间距(detail 按钮始终存在) */
|
||||
export function canvasDataGridActionOverlayWidth(canQuickDownload: boolean, canNavigateForeignKey = false): number {
|
||||
return 22 + (canQuickDownload ? 22 : 0) + (canNavigateForeignKey ? 22 : 0);
|
||||
}
|
||||
|
||||
export function resolveCanvasCellTextLayout(options: { drawX: number; colWidth: number; dpr: number; isRightAlign: boolean; reservedWidth?: number }): { textAnchorX: number; maxWidth: number } {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export function createDataGridCellContextMenuItems(options: {
|
|||
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
|
||||
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
|
||||
downloadItem?: DataGridContextMenuItem | null;
|
||||
foreignKeyItem?: DataGridContextMenuItem | null;
|
||||
copySubmenu: DataGridContextMenuItem;
|
||||
clearSelectionItem?: DataGridContextMenuItem;
|
||||
generateSubmenu?: DataGridContextMenuItem;
|
||||
|
|
@ -138,6 +139,7 @@ export function createDataGridCellContextMenuItems(options: {
|
|||
if (options.hasColumn) {
|
||||
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
|
||||
if (options.downloadItem) items.push(options.downloadItem);
|
||||
if (options.foreignKeyItem) items.push(options.foreignKeyItem);
|
||||
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
|
||||
}
|
||||
items.push({ label: options.labels.rowDetails, action: options.actions.rowDetails, icon: options.icons.rowDetails }, { label: "", separator: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import type { NavigationTarget } from "@/composables/useNavigationTargets";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { ForeignKeyInfo } from "@/types/database";
|
||||
|
||||
export interface ForeignKeyAssociation {
|
||||
foreignKey: ForeignKeyInfo;
|
||||
columnPairs: ForeignKeyInfo[];
|
||||
}
|
||||
|
||||
export interface ForeignKeyAssociationCell {
|
||||
foreignKey: ForeignKeyInfo;
|
||||
columnIndex: number;
|
||||
value: CellValue;
|
||||
}
|
||||
|
||||
function foreignKeyAssociationKey(foreignKey: ForeignKeyInfo): string {
|
||||
return JSON.stringify([foreignKey.name, foreignKey.ref_schema ?? "", foreignKey.ref_table, foreignKey.on_update ?? "", foreignKey.on_delete ?? ""]);
|
||||
}
|
||||
|
||||
/** 列名(小写)→ 外键 association。同列出现在多个 association 时保留第一条。 */
|
||||
export function buildColumnForeignKeyMap(foreignKeys: ForeignKeyInfo[]): Map<string, ForeignKeyAssociation> {
|
||||
const associations = new Map<string, ForeignKeyAssociation>();
|
||||
for (const fk of foreignKeys) {
|
||||
if (!fk.column || !fk.ref_table || !fk.ref_column) continue;
|
||||
const associationKey = foreignKeyAssociationKey(fk);
|
||||
const association = associations.get(associationKey);
|
||||
if (association) association.columnPairs.push(fk);
|
||||
else associations.set(associationKey, { foreignKey: fk, columnPairs: [fk] });
|
||||
}
|
||||
|
||||
const map = new Map<string, ForeignKeyAssociation>();
|
||||
for (const association of associations.values()) {
|
||||
for (const foreignKey of association.columnPairs) {
|
||||
const columnKey = foreignKey.column.toLowerCase();
|
||||
if (!map.has(columnKey)) map.set(columnKey, association);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** NULL/undefined 没有可跳转的目标记录;0、空串、false 都是合法外键值 */
|
||||
export function foreignKeyCellNavigable(value: CellValue | undefined): value is Exclude<CellValue, null> {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
export function foreignKeySourceColumnName(options: { context?: "results" | "table-data"; resultColumns: readonly string[]; sourceColumns?: readonly (string | undefined)[]; columnIndex: number }): string | undefined {
|
||||
const sourceColumn = options.sourceColumns?.[options.columnIndex] || undefined;
|
||||
if (sourceColumn || options.context === "results") return sourceColumn;
|
||||
return options.resultColumns[options.columnIndex] || undefined;
|
||||
}
|
||||
|
||||
export function foreignKeyAssociationCells(options: { association: ForeignKeyAssociation; context?: "results" | "table-data"; resultColumns: readonly string[]; sourceColumns?: readonly (string | undefined)[]; row: readonly (CellValue | undefined)[] }): ForeignKeyAssociationCell[] | undefined {
|
||||
const sourceColumnIndexes = new Map<string, number>();
|
||||
for (let columnIndex = 0; columnIndex < options.resultColumns.length; columnIndex += 1) {
|
||||
const sourceColumn = foreignKeySourceColumnName({
|
||||
context: options.context,
|
||||
resultColumns: options.resultColumns,
|
||||
sourceColumns: options.sourceColumns,
|
||||
columnIndex,
|
||||
});
|
||||
if (sourceColumn && !sourceColumnIndexes.has(sourceColumn.toLowerCase())) sourceColumnIndexes.set(sourceColumn.toLowerCase(), columnIndex);
|
||||
}
|
||||
|
||||
const cells: ForeignKeyAssociationCell[] = [];
|
||||
for (const foreignKey of options.association.columnPairs) {
|
||||
const columnIndex = sourceColumnIndexes.get(foreignKey.column.toLowerCase());
|
||||
if (columnIndex === undefined) return undefined;
|
||||
const value = options.row[columnIndex];
|
||||
if (!foreignKeyCellNavigable(value)) return undefined;
|
||||
cells.push({ foreignKey, columnIndex, value });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function combineForeignKeyConditions(conditions: readonly (string | undefined)[]): string | undefined {
|
||||
if (conditions.length === 0 || conditions.some((condition) => !condition)) return undefined;
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return conditions.map((condition) => `(${condition})`).join(" AND ");
|
||||
}
|
||||
|
||||
export function foreignKeyTableIdentity(options: { connectionId?: string; database?: string; catalog?: string; schema?: string; tableName?: string }): string | undefined {
|
||||
if (!options.connectionId || !options.tableName) return undefined;
|
||||
return JSON.stringify([options.connectionId, options.database ?? "", options.catalog ?? "", options.schema ?? "", options.tableName]);
|
||||
}
|
||||
|
||||
export function foreignKeyMetadataRequestCurrent(options: { requestGeneration: number; currentGeneration: number; requestIdentity: string; currentIdentity?: string }): boolean {
|
||||
return options.requestGeneration === options.currentGeneration && options.requestIdentity === options.currentIdentity;
|
||||
}
|
||||
|
||||
/** 构建跳转到被引用表的导航目标:ref_schema 缺失时回退当前 schema(同 ER 图) */
|
||||
export function foreignKeyNavigationTarget(options: { connectionId: string; database: string; currentSchema?: string; fk: ForeignKeyInfo; whereInput?: string }): NavigationTarget {
|
||||
const schema = options.fk.ref_schema || options.currentSchema || undefined;
|
||||
return {
|
||||
connectionId: options.connectionId,
|
||||
database: options.database,
|
||||
schema,
|
||||
tableName: options.fk.ref_table,
|
||||
columnName: options.fk.ref_column,
|
||||
whereInput: options.whereInput,
|
||||
};
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ test("DataGrid forwards hover action reservation only for right-aligned canvas c
|
|||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
|
||||
assert.match(source, /columnAligns\.value\[cell\.visibleColIdx\] !== "right"/);
|
||||
assert.match(source, /reservedWidth: canvasDataGridActionReservedWidth\(cell\.canQuickDownload\)/);
|
||||
assert.match(source, /reservedWidth: canvasDataGridActionReservedWidth\(cell\.canQuickDownload, !!cell\.foreignKey\)/);
|
||||
assert.match(source, /rightAlignedActionCell: canvasRightAlignedActionCell\.value/);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue