diff --git a/frontend/src/components/DataExportDialog.columns.test.ts b/frontend/src/components/DataExportDialog.columns.test.ts new file mode 100644 index 00000000..a88f6889 --- /dev/null +++ b/frontend/src/components/DataExportDialog.columns.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeDataExportDialogValues, + resolveDataExportColumns, + validateDataExportDialogValues, + type DataExportScopeOption, +} from './DataExportDialog'; + +const scopeOptions: DataExportScopeOption[] = [ + { value: 'all', label: 'All rows' }, +]; + +describe('DataExportDialog column selection', () => { + it('selects every available column by default in source order', () => { + expect(resolveDataExportColumns(undefined, ['id', 'name', 'created_at'])).toEqual([ + 'id', + 'name', + 'created_at', + ]); + + expect(normalizeDataExportDialogValues( + scopeOptions, + { format: 'csv', scope: 'all' }, + false, + ['id', 'name', 'created_at'], + ).columns).toEqual(['id', 'name', 'created_at']); + }); + + it('drops unknown and duplicate selections while preserving available column order', () => { + expect(resolveDataExportColumns( + ['created_at', 'missing', 'id', 'created_at'], + ['id', 'name', 'created_at'], + )).toEqual(['id', 'created_at']); + }); + + it('requires at least one column when columns are available', () => { + const values = normalizeDataExportDialogValues( + scopeOptions, + { format: 'csv', scope: 'all', columns: [] }, + false, + ['id', 'name'], + ); + + expect(validateDataExportDialogValues(values, scopeOptions, false, ['id', 'name'])).toBeTruthy(); + expect(validateDataExportDialogValues( + { ...values, columns: ['name'] }, + scopeOptions, + false, + ['id', 'name'], + )).toBeNull(); + }); + + it('keeps legacy exports without a column catalog unrestricted', () => { + const values = normalizeDataExportDialogValues( + scopeOptions, + { format: 'csv', scope: 'all' }, + false, + ); + + expect(values.columns).toBeUndefined(); + expect(validateDataExportDialogValues(values, scopeOptions)).toBeNull(); + }); +}); diff --git a/frontend/src/components/DataExportDialog.tsx b/frontend/src/components/DataExportDialog.tsx index eb484090..df1378d4 100644 --- a/frontend/src/components/DataExportDialog.tsx +++ b/frontend/src/components/DataExportDialog.tsx @@ -9,6 +9,7 @@ export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; export type DataExportFileOptions = { format: DataExportFormat; + columns?: string[]; xlsxMaxRowsPerSheet?: number; insertSQLDialect?: string; insertSQLTargetTable?: string; @@ -31,6 +32,7 @@ export type DataExportScopeOption = { export type ShowDataExportDialogOptions = { title: string; scopeOptions: DataExportScopeOption[]; + availableColumns?: string[]; initialValues?: Partial; allowInsertSql?: boolean; okText?: string; @@ -66,10 +68,30 @@ const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope return String(firstEnabled?.value || scopeOptions[0]?.value || 'all'); }; -const normalizeDialogValues = ( +export const resolveDataExportColumns = ( + requestedColumns: string[] | undefined, + availableColumns: string[] | undefined, +): string[] | undefined => { + if (!Array.isArray(availableColumns)) return undefined; + + const seenAvailable = new Set(); + const normalizedAvailable = availableColumns.filter((column) => { + const value = String(column ?? ''); + if (!value.trim() || seenAvailable.has(value)) return false; + seenAvailable.add(value); + return true; + }); + if (requestedColumns === undefined) return normalizedAvailable; + + const requested = new Set(requestedColumns.map((column) => String(column ?? ''))); + return normalizedAvailable.filter((column) => requested.has(column)); +}; + +export const normalizeDataExportDialogValues = ( scopeOptions: DataExportScopeOption[], initialValues?: Partial, allowInsertSql = false, + availableColumns?: string[], ): DataExportDialogValues => { const requestedFormat = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat; const format = resolveFormatOptions(allowInsertSql).some((item) => item.value === requestedFormat) @@ -79,17 +101,20 @@ const normalizeDialogValues = ( const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0 ? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet))) : DEFAULT_XLSX_ROWS_PER_SHEET; + const columns = resolveDataExportColumns(initialValues?.columns, availableColumns); return { format, scope, xlsxMaxRowsPerSheet, + ...(columns === undefined ? {} : { columns }), }; }; -const validateDialogValues = ( +export const validateDataExportDialogValues = ( values: DataExportDialogValues, scopeOptions: DataExportScopeOption[], allowInsertSql = false, + availableColumns?: string[], ): string | null => { if (!resolveFormatOptions(allowInsertSql).some((item) => item.value === values.format)) { return t('data_export.dialog.validation.format_required'); @@ -100,6 +125,9 @@ const validateDialogValues = ( return t('data_export.dialog.validation.scope_required'); } } + if (Array.isArray(availableColumns) && (!Array.isArray(values.columns) || values.columns.length === 0)) { + return t('data_export.dialog.validation.columns_required'); + } if (values.format === 'xlsx') { const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0); if (!Number.isFinite(rows) || rows <= 0) { @@ -116,12 +144,25 @@ const validateDialogValues = ( const DataExportDialogContent: React.FC<{ scopeOptions: DataExportScopeOption[]; + availableColumns?: string[]; initialValues?: Partial; allowInsertSql?: boolean; onChange: (values: DataExportDialogValues) => void; -}> = ({ scopeOptions, initialValues, allowInsertSql = false, onChange }) => { - const [values, setValues] = useState(() => normalizeDialogValues(scopeOptions, initialValues, allowInsertSql)); +}> = ({ scopeOptions, availableColumns, initialValues, allowInsertSql = false, onChange }) => { + const [values, setValues] = useState(() => normalizeDataExportDialogValues( + scopeOptions, + initialValues, + allowInsertSql, + availableColumns, + )); const formatOptions = useMemo(() => resolveFormatOptions(allowInsertSql), [allowInsertSql]); + const columnOptions = useMemo( + () => (resolveDataExportColumns(undefined, availableColumns) || []).map((column) => ({ + value: column, + label: column, + })), + [availableColumns], + ); useEffect(() => { onChange(values); @@ -162,6 +203,25 @@ const DataExportDialogContent: React.FC<{ )} + {Array.isArray(availableColumns) && ( + + ({ + value: targetColumn, + label: targetColumn, + disabled: mappedTargetColumns.includes(targetColumn) + && columnMappings[sourceColumn] !== targetColumn, + })), + ]} + onChange={(targetColumn) => setColumnMappings((current) => ({ + ...current, + [sourceColumn]: targetColumn, + }))} + style={{ width: "100%" }} + /> + + ))} + + {mappingValidationError && ( + + )} +
{t("import_preview.preview.table_title")}
diff --git a/frontend/src/components/TableExportWorkbench.test.tsx b/frontend/src/components/TableExportWorkbench.test.tsx index 7d2e5dea..d1278a86 100644 --- a/frontend/src/components/TableExportWorkbench.test.tsx +++ b/frontend/src/components/TableExportWorkbench.test.tsx @@ -3,7 +3,10 @@ import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench'; +import TableExportWorkbench, { + buildTableExportHistoryEntry, + resolveTableExportColumnNames, +} from './TableExportWorkbench'; import { setCurrentLanguage } from '../i18n'; import type { ExportProgressState } from './useExportProgressRunner'; @@ -283,6 +286,27 @@ describe('TableExportWorkbench', () => { expect(source).toContain("t('data_export.label.elapsed')"); }); + it('normalizes table column metadata without changing database order', () => { + expect(resolveTableExportColumnNames([ + { Name: 'id' }, + { name: 'display_name' }, + { COLUMN_NAME: 'created_at' }, + { name: 'display_name' }, + { name: '' }, + ])).toEqual(['id', 'display_name', 'created_at']); + }); + + it('loads selectable columns and sends the selection through both single-table export paths', () => { + const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('DBGetColumns('); + expect(source).toContain('mode="multiple"'); + expect(source).toContain('columns: selectedColumns'); + expect(source).toContain('selectedColumns.length > 0'); + expect(source.match(/ExportQueryWithOptions\(/g)).toHaveLength(2); + expect(source).toContain('ExportTableWithOptions('); + }); + it('prefers backend startedAt over a placeholder history timestamp for the same job', () => { const entry = buildTableExportHistoryEntry({ progressState: { diff --git a/frontend/src/components/TableExportWorkbench.tsx b/frontend/src/components/TableExportWorkbench.tsx index df65b7b4..1b25e979 100644 --- a/frontend/src/components/TableExportWorkbench.tsx +++ b/frontend/src/components/TableExportWorkbench.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Alert, Button, Empty, InputNumber, Select, Tooltip, Typography } from 'antd'; import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons'; import { + DBGetColumns, DBGetDatabases, DBGetTables, ExportDatabasesSQLWithOptions, @@ -18,6 +19,7 @@ import type { TableExportScopeOption, } from '../types'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { getColumnDefinitionName } from '../utils/columnDefinition'; import { resolveConnectionHostSummary } from '../utils/tabDisplay'; import { buildExportWorkbenchHistoryKey } from '../utils/tableExportTab'; import { @@ -32,6 +34,7 @@ import { DEFAULT_DATA_EXPORT_FORMAT, DEFAULT_XLSX_ROWS_PER_SHEET, MAX_XLSX_ROWS_PER_SHEET, + resolveDataExportColumns, type DataExportFormat, } from './DataExportDialog'; import ExportProgressBar from './ExportProgressBar'; @@ -111,6 +114,19 @@ const resolveInitialScope = ( return scopeOptions.find((item) => !item.disabled)?.value || 'all'; }; +export const resolveTableExportColumnNames = (definitions: unknown): string[] => { + if (!Array.isArray(definitions)) return []; + const seen = new Set(); + const columns: string[] = []; + definitions.forEach((definition) => { + const column = getColumnDefinitionName(definition); + if (!column || seen.has(column)) return; + seen.add(column); + columns.push(column); + }); + return columns; +}; + const normalizeConnectionConfig = (connection: SavedConnection) => ({ ...connection.config, port: Number(connection.config.port), @@ -286,14 +302,18 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { const [selectedDbName, setSelectedDbName] = useState(() => String(tab.dbName || '').trim()); const [availableDatabases, setAvailableDatabases] = useState([]); const [availableObjects, setAvailableObjects] = useState([]); + const [availableColumns, setAvailableColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); const [selectedObjectNames, setSelectedObjectNames] = useState([]); const [selectedDatabaseNames, setSelectedDatabaseNames] = useState([]); const [batchTableMode, setBatchTableMode] = useState('schema'); const [batchDatabaseMode, setBatchDatabaseMode] = useState('schema'); const [loadingDatabases, setLoadingDatabases] = useState(false); const [loadingObjects, setLoadingObjects] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); const [databaseLoadError, setDatabaseLoadError] = useState(''); const [objectLoadError, setObjectLoadError] = useState(''); + const [columnLoadError, setColumnLoadError] = useState(''); const effectiveConnectionId = isSingleWorkbench ? String(tab.connectionId || '').trim() : selectedConnectionId; const effectiveDbName = isSingleWorkbench ? String(tab.dbName || '').trim() : selectedDbName; @@ -360,6 +380,50 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { }); }, [scopeOptions, tab.tableExportInitialScope]); + useEffect(() => { + const objectName = String(tab.tableName || '').trim(); + if (!isSingleWorkbench || !connectionConfig || !objectName) { + setAvailableColumns([]); + setSelectedColumns([]); + setColumnLoadError(''); + setLoadingColumns(false); + return undefined; + } + + let alive = true; + setLoadingColumns(true); + setColumnLoadError(''); + DBGetColumns(buildRpcConnectionConfig(connectionConfig) as any, effectiveDbName, objectName) + .then((res) => { + if (!alive) return; + if (!res.success) { + setAvailableColumns([]); + setSelectedColumns([]); + setColumnLoadError(res.message || t('data_export.message.load_columns_failed')); + return; + } + const nextColumns = resolveTableExportColumnNames(res.data); + setAvailableColumns(nextColumns); + setSelectedColumns(nextColumns); + if (nextColumns.length === 0) { + setColumnLoadError(t('data_export.message.load_columns_failed')); + } + }) + .catch((error: any) => { + if (!alive) return; + setAvailableColumns([]); + setSelectedColumns([]); + setColumnLoadError(error?.message || t('data_export.message.load_columns_failed')); + }) + .finally(() => { + if (alive) setLoadingColumns(false); + }); + + return () => { + alive = false; + }; + }, [connectionConfig, effectiveDbName, isSingleWorkbench, tab.tableName]); + useEffect(() => { if (!progressState.startedAt || progressState.finishedAt > 0) return undefined; const timer = window.setInterval(() => { @@ -588,7 +652,12 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { return false; } if (isSingleWorkbench) { - return !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery); + return !!tab.tableName + && !!scope + && !activeScopeOption?.disabled + && !loadingColumns + && selectedColumns.length > 0 + && (scope === 'all' || !!activeScopeQuery); } if (isBatchTablesWorkbench) { return !!selectedDbName && selectedObjectNames.length > 0; @@ -601,9 +670,11 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { isBatchTablesWorkbench, isRunning, isSingleWorkbench, + loadingColumns, scope, selectedDatabaseNames.length, selectedDbName, + selectedColumns.length, selectedObjectNames.length, tab.tableName, ]); @@ -624,6 +695,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { run: (jobId) => { const options = { format, + columns: selectedColumns, xlsxMaxRowsPerSheet, jobId, totalRowsHint: singleTotalRowsKnown ? singleScopeRowCount : 0, @@ -910,6 +982,15 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { /> ) : null} + {columnLoadError ? ( + + ) : null} +
{isSingleWorkbench ? ( <> @@ -942,6 +1023,24 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { />
+
+
{t('data_export.dialog.field.columns')}
+