mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
🐛 fix(query-results): 修复复杂查询固定列失效
为无物理表引用的结果集生成独立固定列作用域。 右键固定和取消固定不再依赖 tableName,保留只读编辑边界。 覆盖多行单表和关联查询的固定列回归。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
33
frontend/src/utils/queryResultColumnPinScope.test.ts
Normal file
33
frontend/src/utils/queryResultColumnPinScope.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
32
frontend/src/utils/queryResultColumnPinScope.ts
Normal file
32
frontend/src/utils/queryResultColumnPinScope.ts
Normal 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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user