diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index c7acf8fb..659a1510 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -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(); + }); + 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(); + }); + 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; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0dae798b..2051f189 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -283,7 +283,7 @@ export { buildDataGridCommitChangeSet, } from './DataGridCore'; const DataGrid: React.FC = ({ - 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 = ({ 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 = ({ supportsCopyInsert, supportsSqlQueryExport, tableName, + pinnedLeftColumnScope, toggleColumnVisibility, pinnedLeftColumnNames, setTablePinnedLeftColumns, @@ -4614,6 +4616,7 @@ const DataGrid: React.FC = ({ panelPaddingY, panelRadius, pendingChangeCount, + pinnedLeftColumnScope, pinnedLeftColumnSet, pkColumns, prefersManualTotalCount, diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index 9bb02413..27d93703 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -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; diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index 2b251e5d..eea820bd 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -243,6 +243,7 @@ const DataGridShell: React.FC = (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} /> ); diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 144a24b5..18ece7e4 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -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) => (
@@ -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', () => { ) : null), Input: ({ value, onChange, placeholder }: any) => , + 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(); + }); + + 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', diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 2d7d5b04..3464d680 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -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 = ({ ); } const visibleColumns = resolveVisibleQueryResultColumns(rs.columns, globalHiddenColumns); + const resultTableName = rs.metadataTableName || rs.tableName; return (
{Array.isArray(rs.messages) && rs.messages.length > 0 ? ( @@ -456,7 +458,12 @@ const QueryEditorResultsPanel: React.FC = ({ 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} diff --git a/frontend/src/components/useDataGridV2Actions.ts b/frontend/src/components/useDataGridV2Actions.ts index 01290ed5..7b591288 100644 --- a/frontend/src/components/useDataGridV2Actions.ts +++ b/frontend/src/components/useDataGridV2Actions.ts @@ -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, ]); diff --git a/frontend/src/utils/queryResultColumnPinScope.test.ts b/frontend/src/utils/queryResultColumnPinScope.test.ts new file mode 100644 index 00000000..70c188b6 --- /dev/null +++ b/frontend/src/utils/queryResultColumnPinScope.test.ts @@ -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 }), + ); + }); +}); diff --git a/frontend/src/utils/queryResultColumnPinScope.ts b/frontend/src/utils/queryResultColumnPinScope.ts new file mode 100644 index 00000000..7b843b6e --- /dev/null +++ b/frontend/src/utils/queryResultColumnPinScope.ts @@ -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)}`; +};