diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx
index ae8d0315..c9e1db07 100644
--- a/frontend/src/components/DataViewer.primary-key.test.tsx
+++ b/frontend/src/components/DataViewer.primary-key.test.tsx
@@ -165,6 +165,52 @@ describe('DataViewer safe editing locator', () => {
renderer!.unmount();
});
+ it('does not block the initial Kingbase table query on edit-locator metadata', async () => {
+ storeState.connections[0].config.type = 'kingbase';
+ storeState.connections[0].config.database = 'ldf_server_dbs_dev';
+
+ let resolveColumns!: (value: any) => void;
+ let resolveIndexes!: (value: any) => void;
+ backendApp.DBGetColumns.mockReturnValue(new Promise((resolve) => {
+ resolveColumns = resolve;
+ }));
+ backendApp.DBGetIndexes.mockReturnValue(new Promise((resolve) => {
+ resolveIndexes = resolve;
+ }));
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await flushPromises();
+
+ expect(backendApp.DBQuery).toHaveBeenCalled();
+ expect(dataGridState.latestProps?.data).toEqual([
+ expect.objectContaining({ ID: 7, NAME: 'old-name' }),
+ ]);
+
+ await act(async () => {
+ resolveColumns({
+ success: true,
+ data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
+ });
+ resolveIndexes({ success: true, data: [] });
+ });
+ await flushPromises();
+
+ expect(dataGridState.latestProps?.editLocator).toMatchObject({
+ strategy: 'primary-key',
+ columns: ['ID'],
+ readOnly: false,
+ });
+ renderer!.unmount();
+ });
+
it('enables table preview editing after primary keys are loaded', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx
index bb7de2c0..ba3b992a 100644
--- a/frontend/src/components/DataViewer.tsx
+++ b/frontend/src/components/DataViewer.tsx
@@ -644,24 +644,28 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
pkKeyRef.current = locatorKey;
const locatorSeq = ++pkSeqRef.current;
- try {
- const [resCols, resIndexes] = await Promise.all([
- DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
- DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
- .catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
- ]);
- if (fetchSeqRef.current !== seq) return;
- if (pkSeqRef.current !== locatorSeq) return;
- if (pkKeyRef.current !== locatorKey) return;
+ const loadEditLocator = async (): Promise<{
+ primaryKeys: string[];
+ locator: EditRowLocator;
+ } | null> => {
+ try {
+ const [resCols, resIndexes] = await Promise.all([
+ DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
+ DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
+ .catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
+ ]);
+ if (fetchSeqRef.current !== seq) return null;
+ if (pkSeqRef.current !== locatorSeq) return null;
+ if (pkKeyRef.current !== locatorKey) return null;
+
+ if (!resCols?.success || !Array.isArray(resCols.data)) {
+ const nextLocator = buildAllColumnsLocator([], { translate: tr });
+ setPkColumns([]);
+ setEditLocator(nextLocator);
+ if (nextLocator.reason) message.info(nextLocator.reason);
+ return { primaryKeys: [], locator: nextLocator };
+ }
- if (!resCols?.success || !Array.isArray(resCols.data)) {
- const nextLocator = buildAllColumnsLocator([], { translate: tr });
- pkColumnsForQuery = [];
- editLocatorForQuery = nextLocator;
- setPkColumns([]);
- setEditLocator(nextLocator);
- if (nextLocator.reason) message.info(nextLocator.reason);
- } else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
.filter((column: any) => getColumnDefinitionKey(column) === 'PRI')
@@ -686,8 +690,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
translate: tr,
}), tr);
- pkColumnsForQuery = primaryKeys;
- editLocatorForQuery = nextLocator;
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
@@ -695,17 +697,28 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
} else if (nextLocator.strategy === 'all-columns' && nextLocator.reason) {
message.info(nextLocator.reason);
}
+ return { primaryKeys, locator: nextLocator };
+ } catch {
+ if (fetchSeqRef.current !== seq) return null;
+ if (pkSeqRef.current !== locatorSeq) return null;
+ if (pkKeyRef.current !== locatorKey) return null;
+ const nextLocator = buildAllColumnsLocator([], { translate: tr });
+ setPkColumns([]);
+ setEditLocator(nextLocator);
+ if (nextLocator.reason) message.info(nextLocator.reason);
+ return { primaryKeys: [], locator: nextLocator };
}
- } catch {
- if (fetchSeqRef.current !== seq) return;
- if (pkSeqRef.current !== locatorSeq) return;
- if (pkKeyRef.current !== locatorKey) return;
- const nextLocator = buildAllColumnsLocator([], { translate: tr });
- pkColumnsForQuery = [];
- editLocatorForQuery = nextLocator;
- setPkColumns([]);
- setEditLocator(nextLocator);
- if (nextLocator.reason) message.info(nextLocator.reason);
+ };
+
+ if (dbTypeLower === 'kingbase') {
+ // Kingbase catalog metadata can be noticeably slower than the page query.
+ // Keep the grid read-only briefly and enable editing when the locator arrives.
+ void loadEditLocator();
+ } else {
+ const locatorMetadata = await loadEditLocator();
+ if (!locatorMetadata) return;
+ pkColumnsForQuery = locatorMetadata.primaryKeys;
+ editLocatorForQuery = locatorMetadata.locator;
}
}
}
diff --git a/frontend/src/components/useDataGridErDiagram.test.ts b/frontend/src/components/useDataGridErDiagram.test.ts
index 3506a248..f4b953fe 100644
--- a/frontend/src/components/useDataGridErDiagram.test.ts
+++ b/frontend/src/components/useDataGridErDiagram.test.ts
@@ -1,11 +1,13 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ForeignKeyDefinition } from '../types';
import type { ErDiagramTableSnapshot } from './dataGridErDiagramModel';
import { collectErDiagramNeighborhood, useDataGridErDiagram } from './useDataGridErDiagram';
const backendApp = vi.hoisted(() => ({
DBGetColumns: vi.fn(),
+ DBGetDatabaseForeignKeys: vi.fn(),
DBGetForeignKeys: vi.fn(),
DBGetIndexes: vi.fn(),
DBGetTables: vi.fn(),
@@ -128,12 +130,62 @@ describe('collectErDiagramNeighborhood', () => {
);
expect(twoHop.canExpandRelations).toBe(false);
});
+
+ it('uses a prefetched foreign-key snapshot instead of scanning every table', async () => {
+ const unrelatedTables = Array.from({ length: 500 }, (_, index) => `unrelated_${index}`);
+ const schemaTableNames = ['orders', 'customers', 'order_items', ...unrelatedTables];
+ const loadSnapshot = vi.fn(async (tableName: string) => ({
+ tableName,
+ columns: [],
+ foreignKeys: [],
+ uniqueKeyGroups: [],
+ }));
+ const loadForeignKeys = vi.fn(async () => []);
+ const prefetchedForeignKeysByTable = new Map(
+ schemaTableNames.map((tableName) => [tableName, []]),
+ );
+ prefetchedForeignKeysByTable.set('orders', [{
+ name: 'fk_orders_customer',
+ columnName: 'customer_id',
+ refTableName: 'customers',
+ refColumnName: 'id',
+ constraintName: 'fk_orders_customer',
+ }]);
+ prefetchedForeignKeysByTable.set('order_items', [{
+ name: 'fk_items_order',
+ columnName: 'order_id',
+ refTableName: 'orders',
+ refColumnName: 'id',
+ constraintName: 'fk_items_order',
+ }]);
+
+ const result = await collectErDiagramNeighborhood({
+ currentSnapshot: {
+ tableName: 'orders',
+ columns: [],
+ foreignKeys: [],
+ uniqueKeyGroups: [],
+ },
+ schemaTableNames,
+ relationDepth: 1,
+ loadSnapshot,
+ loadForeignKeys,
+ resolveTableName: (tableName) => tableName,
+ prefetchedForeignKeysByTable,
+ });
+
+ expect(result.relations.map((relation) => `${relation.sourceTableName}->${relation.targetTableName}`)).toEqual(
+ expect.arrayContaining(['orders->customers', 'order_items->orders']),
+ );
+ expect(loadForeignKeys).not.toHaveBeenCalled();
+ });
});
describe('useDataGridErDiagram cache invalidation', () => {
beforeEach(() => {
Object.values(backendApp).forEach((mock) => mock.mockReset());
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
+ backendApp.DBGetDatabaseForeignKeys.mockResolvedValue({ success: true, data: {} });
backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [{ table: 'orders' }] });
@@ -201,4 +253,50 @@ describe('useDataGridErDiagram cache invalidation', () => {
renderer?.unmount();
});
});
+
+ it('loads one Kingbase foreign-key snapshot instead of querying every table', async () => {
+ const unrelatedTables = Array.from({ length: 500 }, (_, index) => ({
+ table: `ldf_server.unrelated_${index}`,
+ }));
+ backendApp.DBGetTables.mockResolvedValue({
+ success: true,
+ data: [{ table: 'ldf_server.orders' }, ...unrelatedTables],
+ });
+
+ let controller: ReturnType | null = null;
+ let renderer: ReactTestRenderer | null = null;
+ const params = {
+ connections: [{
+ id: 'kingbase-er-snapshot-test',
+ config: {
+ type: 'kingbase',
+ host: '127.0.0.1',
+ port: 54321,
+ database: 'ldf_server_dbs_dev',
+ },
+ }],
+ connectionId: 'kingbase-er-snapshot-test',
+ dbName: 'ldf_server_dbs_dev',
+ tableName: 'ldf_server.orders',
+ };
+ const Harness = () => {
+ controller = useDataGridErDiagram(params);
+ return null;
+ };
+
+ await act(async () => {
+ renderer = create(React.createElement(Harness));
+ await vi.waitFor(() => {
+ expect(controller?.loading).toBe(false);
+ expect(controller?.graph).not.toBeNull();
+ });
+ });
+
+ expect(backendApp.DBGetDatabaseForeignKeys).toHaveBeenCalledTimes(1);
+ expect(backendApp.DBGetForeignKeys).not.toHaveBeenCalled();
+
+ act(() => {
+ renderer?.unmount();
+ });
+ });
});
diff --git a/frontend/src/components/useDataGridErDiagram.ts b/frontend/src/components/useDataGridErDiagram.ts
index b1f244cc..39a539e1 100644
--- a/frontend/src/components/useDataGridErDiagram.ts
+++ b/frontend/src/components/useDataGridErDiagram.ts
@@ -1,9 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { DBGetColumns, DBGetForeignKeys, DBGetIndexes, DBGetTables } from '../../wailsjs/go/app/App';
+import {
+ DBGetColumns,
+ DBGetDatabaseForeignKeys,
+ DBGetForeignKeys,
+ DBGetIndexes,
+ DBGetTables,
+} from '../../wailsjs/go/app/App';
import type { ColumnDefinition, ForeignKeyDefinition } from '../types';
import { createBoundedAsyncCache } from '../utils/boundedAsyncCache';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { normalizeColumnDefinitions } from '../utils/columnDefinition';
+import { resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import {
buildErDiagramGraph,
@@ -38,6 +45,7 @@ const ER_SCHEMA_CACHE_MAX_ENTRIES = 32;
const ER_TABLE_METADATA_CACHE_MAX_ENTRIES = 256;
const schemaTableNamesCache = createBoundedAsyncCache(ER_SCHEMA_CACHE_MAX_ENTRIES);
+const databaseForeignKeysCache = createBoundedAsyncCache