🐛 fix(query-results): 修复复杂查询固定列失效

为无物理表引用的结果集生成独立固定列作用域。

右键固定和取消固定不再依赖 tableName,保留只读编辑边界。

覆盖多行单表和关联查询的固定列回归。
This commit is contained in:
Syngnat
2026-07-10 14:46:38 +08:00
parent e1fb5b12d4
commit b23c1ba932
9 changed files with 199 additions and 15 deletions

View File

@@ -55,6 +55,7 @@ const storeState = vi.hoisted(() => ({
setActiveContext: vi.fn(),
tableColumnOrders: {},
tablePinnedLeftColumns: {},
setTablePinnedLeftColumns: vi.fn(),
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
@@ -860,6 +861,8 @@ describe('DataGrid DDL interactions', () => {
});
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
storeState.tablePinnedLeftColumns = {};
storeState.setTablePinnedLeftColumns.mockReset();
testRenderState.latestColumns = [];
testRenderState.latestTableProps = null;
testRenderState.latestMonacoMouseDownListeners = [];
@@ -1084,6 +1087,64 @@ describe('DataGrid DDL interactions', () => {
renderer!.unmount();
});
it('pins a read-only query-result column with an independent pin scope', async () => {
storeState.appearance.uiVersion = 'v2';
const columnPinScope = 'query-result:1a2b3c4d';
const props = {
data: [{ __gonavi_row_key__: 'row-1', id: 1, id_2: 2, order_id: 100 }],
columnNames: ['id', 'id_2', 'order_id'],
loading: false,
dbName: 'main',
connectionId: 'conn-1',
columnPinScope,
};
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataGrid {...props} />);
});
await waitForEffects();
const duplicateIdColumn = testRenderState.latestColumns.find((column) => column.key === 'id_2');
expect(duplicateIdColumn).toBeTruthy();
expect(duplicateIdColumn.fixed).toBeUndefined();
const headerProps = duplicateIdColumn.onHeaderCell(duplicateIdColumn);
await act(async () => {
headerProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 120,
clientY: 88,
});
});
await act(async () => {
findButton(renderer!, t('data_grid.context_menu.pin_column_left')).props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(storeState.setTablePinnedLeftColumns).toHaveBeenCalledWith(
'conn-1',
'main',
columnPinScope,
['id_2'],
);
storeState.tablePinnedLeftColumns = {
'conn-1-main-query-result:1a2b3c4d': ['id_2'],
};
await act(async () => {
renderer!.update(<DataGrid {...props} data={[...props.data]} />);
});
await waitForEffects();
expect(testRenderState.latestColumns.find((column) => column.key === 'id_2').fixed).toBe('left');
renderer!.unmount();
});
it('retries column metadata loading when the first response has no usable type or comment', async () => {
storeState.queryOptions.showColumnComment = true;
storeState.queryOptions.showColumnType = true;

View File

@@ -283,7 +283,7 @@ export {
buildDataGridCommitChangeSet,
} from './DataGridCore';
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
resultSql,
resultExportAllSql,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
@@ -481,16 +481,17 @@ const DataGrid: React.FC<DataGridProps> = ({
setAllOrderedColumnNames(nextOrder);
}, [visibleColumnNames, tableColumnOrders, enableColumnOrderMemory, connectionId, dbName, tableName]);
const tableMemoryKey = useMemo(() => {
if (!connectionId || !dbName || !tableName) return '';
return `${connectionId}-${dbName}-${tableName}`;
}, [connectionId, dbName, tableName]);
const pinnedLeftColumnScope = columnPinScope || tableName;
const pinnedLeftColumnMemoryKey = useMemo(() => {
if (!connectionId || !dbName || !pinnedLeftColumnScope) return '';
return `${connectionId}-${dbName}-${pinnedLeftColumnScope}`;
}, [connectionId, dbName, pinnedLeftColumnScope]);
const pinnedLeftColumnNames = useMemo(() => {
if (!tableMemoryKey) return [] as string[];
const stored = tablePinnedLeftColumns?.[tableMemoryKey];
if (!pinnedLeftColumnMemoryKey) return [] as string[];
const stored = tablePinnedLeftColumns?.[pinnedLeftColumnMemoryKey];
return Array.isArray(stored) ? stored.map((col) => String(col || '').trim()).filter(Boolean) : [];
}, [tableMemoryKey, tablePinnedLeftColumns]);
}, [pinnedLeftColumnMemoryKey, tablePinnedLeftColumns]);
const pinnedLeftColumnSet = useMemo(
() => new Set(pinnedLeftColumnNames),
@@ -3278,6 +3279,7 @@ const DataGrid: React.FC<DataGridProps> = ({
supportsCopyInsert,
supportsSqlQueryExport,
tableName,
pinnedLeftColumnScope,
toggleColumnVisibility,
pinnedLeftColumnNames,
setTablePinnedLeftColumns,
@@ -4614,6 +4616,7 @@ const DataGrid: React.FC<DataGridProps> = ({
panelPaddingY,
panelRadius,
pendingChangeCount,
pinnedLeftColumnScope,
pinnedLeftColumnSet,
pkColumns,
prefersManualTotalCount,

View File

@@ -1315,6 +1315,8 @@ interface DataGridProps {
columnNames: string[];
loading: boolean;
tableName?: string;
/** Optional display-state identity for query results without a physical table. */
columnPinScope?: string;
objectType?: 'table' | 'view' | 'materialized-view';
exportScope?: 'table' | 'queryResult';
resultSql?: string;

View File

@@ -243,6 +243,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (props) => {
panelPaddingY,
panelRadius,
pendingChangeCount,
pinnedLeftColumnScope,
pinnedLeftColumnSet,
pkColumns,
prefersManualTotalCount,
@@ -875,7 +876,7 @@ const renderDataTableView = () => (
showColumnType={showColumnType}
showColumnComment={showColumnComment}
pinnedLeft={Boolean(pinnedLeftColumnSet?.has?.(fieldName))}
canPinLeft={Boolean(connectionId && dbName && tableName)}
canPinLeft={Boolean(connectionId && dbName && pinnedLeftColumnScope)}
onAction={handleV2ColumnHeaderContextMenuAction}
/>
);

View File

@@ -361,6 +361,14 @@ vi.mock('./DataGrid', () => ({
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('./resultDiff/ResultDiffWizard', () => ({
default: () => null,
}));
vi.mock('./resultDiff/ViewDataVerifyWizard', () => ({
default: () => null,
}));
vi.mock('./LogPanel', () => ({
default: ({ executionError }: any) => (
<div data-log-panel="true">
@@ -381,6 +389,7 @@ vi.mock('@ant-design/icons', () => {
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
DownOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
DatabaseOutlined: Icon,
@@ -434,6 +443,7 @@ vi.mock('antd', () => {
</section>
) : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Segmented: () => null,
Form,
Dropdown: ({ children, menu }: any) => (
<>
@@ -3539,6 +3549,40 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('gives a multiline single-table result an independent column pin scope without making it editable', async () => {
const sql = [
'SELECT a.COMPID, a.MEMCARDNO,',
' a.MODIFYUSER, a.MODIFYTIME',
'FROM D_MEMBER_CARDTYPE_MODFIY_LOG a',
].join('\n');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['COMPID', 'MEMCARDNO', 'MODIFYUSER', 'MODIFYTIME'],
rows: [{ COMPID: 1, MEMCARDNO: 'M-1', MODIFYUSER: 'admin', MODIFYTIME: '2026-07-10' }],
}],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: sql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBeUndefined();
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(dataGridState.latestProps?.columnPinScope).toMatch(/^query-result:[a-f0-9]+$/);
expect(dataGridState.latestProps?.columnPinScope).not.toContain('D_MEMBER_CARDTYPE_MODFIY_LOG');
renderer!.unmount();
});
it.each([
'mysql',
'mariadb',

View File

@@ -5,6 +5,7 @@ import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOu
import type { EditRowLocator } from '../utils/rowLocator';
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns';
import { buildQueryResultColumnPinScope } from '../utils/queryResultColumnPinScope';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
@@ -440,6 +441,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
);
}
const visibleColumns = resolveVisibleQueryResultColumns(rs.columns, globalHiddenColumns);
const resultTableName = rs.metadataTableName || rs.tableName;
return (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{Array.isArray(rs.messages) && rs.messages.length > 0 ? (
@@ -456,7 +458,12 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
data={rs.rows}
columnNames={visibleColumns}
loading={loading || rs.page?.loading === true}
tableName={rs.metadataTableName || rs.tableName}
tableName={resultTableName}
columnPinScope={resultTableName ? undefined : buildQueryResultColumnPinScope({
sql: rs.exportSql || rs.sql,
sourceStatementIndex: rs.sourceStatementIndex,
statementResultIndex: rs.statementResultIndex,
})}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
resultExportAllSql={rs.page?.exportAllSql}

View File

@@ -101,6 +101,7 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => {
supportsCopyInsert,
supportsSqlQueryExport,
tableName,
pinnedLeftColumnScope,
toggleColumnVisibility,
pinnedLeftColumnNames,
setTablePinnedLeftColumns,
@@ -137,7 +138,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
autoFitColumnWidth(columnName);
break;
case 'pin-column-left': {
if (!connectionId || !dbName || !tableName) {
if (!connectionId || !dbName || !pinnedLeftColumnScope) {
void message.info(translateDataGrid('data_grid.message.pin_column_unavailable'));
break;
}
@@ -145,13 +146,13 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
...pinnedLeftColumnNames.filter((col: string) => col !== columnName),
columnName,
];
setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
setTablePinnedLeftColumns(connectionId, dbName, pinnedLeftColumnScope, nextPinned);
break;
}
case 'unpin-column-left': {
if (!connectionId || !dbName || !tableName) break;
if (!connectionId || !dbName || !pinnedLeftColumnScope) break;
const nextPinned = pinnedLeftColumnNames.filter((col: string) => col !== columnName);
setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
setTablePinnedLeftColumns(connectionId, dbName, pinnedLeftColumnScope, nextPinned);
break;
}
case 'hide-column':
@@ -190,7 +191,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
pinnedLeftColumnNames,
setQueryOptions,
setTablePinnedLeftColumns,
tableName,
pinnedLeftColumnScope,
translateDataGrid,
toggleColumnVisibility,
]);

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { buildQueryResultColumnPinScope } from './queryResultColumnPinScope';
describe('buildQueryResultColumnPinScope', () => {
it('is stable for the same SQL without persisting the SQL text', () => {
const input = {
sql: 'SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id',
sourceStatementIndex: 1,
statementResultIndex: 1,
};
const scope = buildQueryResultColumnPinScope(input);
expect(scope).toBe(buildQueryResultColumnPinScope(input));
expect(scope).toMatch(/^query-result:[a-f0-9]+$/);
expect(scope).not.toContain('users');
});
it('keeps distinct result sets in separate scopes', () => {
const base = {
sql: 'SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id',
sourceStatementIndex: 1,
statementResultIndex: 1,
};
expect(buildQueryResultColumnPinScope(base)).not.toBe(
buildQueryResultColumnPinScope({ ...base, statementResultIndex: 2 }),
);
expect(buildQueryResultColumnPinScope(base)).not.toBe(
buildQueryResultColumnPinScope({ ...base, sourceStatementIndex: 2 }),
);
});
});

View File

@@ -0,0 +1,32 @@
export type QueryResultColumnPinScopeInput = {
sql: string;
sourceStatementIndex?: number;
statementResultIndex?: number;
};
const normalizeResultIndex = (value: number | undefined): number => {
const parsed = Math.floor(Number(value));
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
};
const hashQueryResultIdentity = (value: string): string => {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
};
export const buildQueryResultColumnPinScope = ({
sql,
sourceStatementIndex,
statementResultIndex,
}: QueryResultColumnPinScopeInput): string => {
const identity = [
normalizeResultIndex(sourceStatementIndex),
normalizeResultIndex(statementResultIndex),
String(sql || ''),
].join('\u0000');
return `query-result:${hashQueryResultIdentity(identity)}`;
};