diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d661a45c..833e3a90 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2247,6 +2247,7 @@ const Sidebar: React.FC<{ const { handleCopyStructure, + handleCopyTable, handleCopyTableName, handleCopyDatabaseName, handleExport, @@ -2403,6 +2404,7 @@ const Sidebar: React.FC<{ openTableDdlInDesigner, openTableInERView, handleCopyTableName, + handleCopyTable, handleCopyDatabaseName, handleCopyStructure, handleCopyTableAsInsert, @@ -2652,6 +2654,7 @@ const Sidebar: React.FC<{ openDesign, openCreateStarRocksRollup, handleCopyTableName, + handleCopyTable, handleCopyStructure, handleExport, setRenameTableTarget, diff --git a/frontend/src/components/TableCopy.i18n.test.tsx b/frontend/src/components/TableCopy.i18n.test.tsx new file mode 100644 index 00000000..3f8cbd1c --- /dev/null +++ b/frontend/src/components/TableCopy.i18n.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { setCurrentLanguage } from '../i18n'; +import { V2TableContextMenuView } from './V2TableContextMenu'; + +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const catalogs = Object.fromEntries(locales.map((locale) => [ + locale, + JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record, +])) as Record>; + +const placeholders = (value: string): string[] => [...value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)] + .map((match) => match[1]) + .sort(); + +const requiredKeys = [ + 'connection.backend.action.copy_table', + 'db.backend.error.table_copy_unsupported', + 'db.backend.error.table_copy_list_failed', + 'db.backend.error.table_copy_create_failed', + 'db.backend.error.table_copy_data_failed', + 'db.backend.error.table_copy_cleanup_failed', + 'db.backend.error.table_copy_unsafe_storage', + 'db.backend.message.table_copied', + 'table_copy.action.label', + 'table_copy.message.backend_unavailable', + 'table_copy.message.failed', + 'table_copy.message.loading', + 'table_copy.message.refresh_failed', + 'table_copy.message.success', + 'table_copy.message.target_missing', + 'table_copy.message.unsupported', + 'table_copy.modal.content', + 'table_copy.modal.title', +] as const; + +describe('whole-table copy i18n and action wiring', () => { + it('keeps frontend and backend copy feedback localized with matching placeholders', () => { + requiredKeys.forEach((key) => { + const expected = placeholders(catalogs['zh-CN'][key]); + locales.forEach((locale) => { + expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key); + expect(placeholders(catalogs[locale][key]), `${locale}:${key}`).toEqual(expected); + }); + }); + }); + + it('shows whole-table copy in the shared v2 copy group only when supported', () => { + setCurrentLanguage('zh-CN'); + + const supported = renderToStaticMarkup( + , + ); + const unsupported = renderToStaticMarkup( + , + ); + + expect(supported).toContain('复制整表'); + expect(unsupported).not.toContain('复制整表'); + }); + + it('routes overview, v2 sidebar and legacy sidebar actions through the confirmed copy flow', () => { + const overview = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8'); + const objectActions = readFileSync(new URL('./sidebar/useSidebarObjectActions.tsx', import.meta.url), 'utf8'); + const v2Actions = readFileSync(new URL('./sidebar/useSidebarV2ActionHandlers.tsx', import.meta.url), 'utf8'); + const legacyMenu = readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8'); + + expect(overview).toContain('confirmCopyTable({'); + expect(overview).toContain('await loadData();'); + expect(overview).toContain('supportsCopyTable={supportsCopyTable}'); + expect(objectActions).toContain('confirmCopyTable({'); + expect(objectActions).toContain('await loadTables(getDatabaseNodeRef(conn, conn.dbName));'); + expect(v2Actions).toContain("case 'copy-table':"); + expect(legacyMenu).toContain("key: 'copy-table'"); + expect(legacyMenu).toContain("label: t('table_copy.action.label')"); + }); +}); diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index a9013f1e..17d197a9 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -30,6 +30,7 @@ import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; import { showSQLExportOptionsDialog } from './SQLExportOptionsDialog'; +import { confirmCopyTable } from './tableCopyAction'; interface TableOverviewProps { tab: TabData; @@ -283,6 +284,7 @@ const TableOverview: React.FC = ({ tab }) => { ); const schemaName = String((tab as any).schemaName || '').trim(); const supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner; + const supportsCopyTable = getDataSourceCapabilities(connection?.config).supportsCopyTable; const autoFetchVisible = useAutoFetchVisibility(); const loadData = useCallback(async () => { @@ -582,6 +584,24 @@ const TableOverview: React.FC = ({ tab }) => { } }, [t]); + const handleCopyTable = useCallback((tableName: string) => { + if (!supportsCopyTable) { + message.warning(t('table_copy.message.unsupported')); + return; + } + const config = buildConfig(); + if (!config) return; + confirmCopyTable({ + config: buildRpcConnectionConfig(config) as any, + dbName: tab.dbName || '', + sourceSchemaName: schemaName, + sourceTableName: tableName, + onSuccess: async () => { + await loadData(); + }, + }); + }, [buildConfig, loadData, schemaName, supportsCopyTable, t, tab.dbName]); + const openTableSQLExportWorkbench = useCallback(async (tableName: string, mode: 'backup' | 'dataOnly') => { const normalizedTableName = String(tableName || '').trim(); if (!normalizedTableName) return; @@ -888,6 +908,9 @@ const TableOverview: React.FC = ({ tab }) => { case 'copy-structure': void handleCopyStructure(tableName); return; + case 'copy-table': + handleCopyTable(tableName); + return; case 'copy-insert': void handleCopyTableAsInsert(tableName); return; @@ -923,6 +946,7 @@ const TableOverview: React.FC = ({ tab }) => { } }, [ handleCopyStructure, + handleCopyTable, handleCopyTableAsInsert, handleCopyTableName, handleDeleteTable, @@ -954,13 +978,14 @@ const TableOverview: React.FC = ({ tab }) => { }} isPinned={isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name)} supportsTruncate={allowTruncate} + supportsCopyTable={supportsCopyTable} supportsStarRocksRollup={metadataDialect === 'starrocks'} onAction={(action) => { setV2ContextMenu(null); handleV2TableContextMenuAction(table, action); }} /> - ), [activeShortcutPlatform, allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]); + ), [activeShortcutPlatform, allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, supportsCopyTable, tab.dbName]); const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [ { key: 'new-query', label: t('table_overview.menu.new_query'), icon: , onClick: () => openQueryForTable(table.name) }, @@ -973,6 +998,7 @@ const TableOverview: React.FC = ({ tab }) => { }, { key: 'copy-table-name', label: t('table_overview.menu.copy_table_name'), icon: , onClick: () => handleCopyTableName(table.name) }, { key: 'copy-structure', label: t('table_overview.menu.copy_structure'), icon: , onClick: () => handleCopyStructure(table.name) }, + ...(supportsCopyTable ? [{ key: 'copy-table', label: t('table_copy.action.label'), icon: , onClick: () => handleCopyTable(table.name) }] : []), { key: 'backup-table', label: t('table_overview.menu.backup_table_sql'), icon: , onClick: () => openTableSQLExportWorkbench(table.name, 'backup') }, { key: 'rename-table', label: t('table_overview.menu.rename_table'), icon: , onClick: () => handleRenameTable(table.name) }, { key: 'danger-zone', label: t('table_overview.menu.danger_operations'), icon: , children: [ @@ -985,6 +1011,7 @@ const TableOverview: React.FC = ({ tab }) => { ], [ allowTruncate, handleCopyStructure, + handleCopyTable, handleCopyTableName, handleDeleteTable, handleRenameTable, @@ -994,6 +1021,7 @@ const TableOverview: React.FC = ({ tab }) => { openQueryForTable, openTableSQLExportWorkbench, supportsDesignWrite, + supportsCopyTable, t, ]); diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index fc1f0bad..c37e7545 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -49,6 +49,7 @@ export type V2TableContextMenuActionKey = | 'view-er' | 'copy-table-name' | 'copy-structure' + | 'copy-table' | 'copy-insert' | 'rename-table' | 'new-rollup' @@ -167,6 +168,7 @@ export const V2TableContextMenuView: React.FC<{ stats?: V2TableContextMenuStats; isPinned?: boolean; supportsTruncate?: boolean; + supportsCopyTable?: boolean; supportsStarRocksRollup?: boolean; supportsMessagePublish?: boolean; onAction?: (action: V2TableContextMenuActionKey) => void; @@ -176,6 +178,7 @@ export const V2TableContextMenuView: React.FC<{ stats, isPinned = false, supportsTruncate = true, + supportsCopyTable = false, supportsStarRocksRollup = false, supportsMessagePublish = false, onAction, @@ -237,6 +240,7 @@ export const V2TableContextMenuView: React.FC<{ {renderItems([ { action: 'copy-table-name', icon: , title: t('sidebar.v2_table_menu.copy_table_name'), kbd: primaryShortcut('C', shortcutPlatform) }, { action: 'copy-structure', icon: , title: `${t('sidebar.menu.copy_table_structure')} · DDL` }, + ...(supportsCopyTable ? [{ action: 'copy-table' as const, icon: , title: t('table_copy.action.label') }] : []), { action: 'copy-insert', icon: , title: t('sidebar.v2_table_menu.copy_table_as_insert', { keyword: 'INSERT' }) }, ])} diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx index 0ebd9571..c00aef70 100644 --- a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx +++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx @@ -185,6 +185,7 @@ export const buildSidebarLegacyNodeMenuItems = ( openDesign, openCreateStarRocksRollup, handleCopyTableName, + handleCopyTable, handleCopyStructure, handleExport, setRenameTableTarget, @@ -935,6 +936,7 @@ export const buildSidebarLegacyNodeMenuItems = ( ]; } else if (node.type === 'table') { const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const supportsCopyTable = getDataSourceCapabilities(node.dataRef?.config).supportsCopyTable; const messagePublishTarget = resolveMessagePublishTarget(node); return [ { @@ -987,6 +989,12 @@ export const buildSidebarLegacyNodeMenuItems = ( icon: , onClick: () => handleCopyStructure(node) }, + ...(supportsCopyTable ? [{ + key: 'copy-table', + label: t('table_copy.action.label'), + icon: , + onClick: () => handleCopyTable(node) + }] : []), { key: 'backup-table', label: t('sidebar.menu.backup_table_sql'), diff --git a/frontend/src/components/sidebar/useSidebarObjectActions.tsx b/frontend/src/components/sidebar/useSidebarObjectActions.tsx index e8c83cc5..6cf02e60 100644 --- a/frontend/src/components/sidebar/useSidebarObjectActions.tsx +++ b/frontend/src/components/sidebar/useSidebarObjectActions.tsx @@ -14,6 +14,7 @@ import { buildStarRocksMaterializedViewPreviewSql } from '../tableDesignerSchema import type { ExportRunResult, RunExportWithProgressOptions } from '../useExportProgressRunner'; import { getTableDataDangerActionMeta, type TableDataDangerActionKind } from '../tableDataDangerActions'; import { showSQLExportOptionsDialog } from '../SQLExportOptionsDialog'; +import { confirmCopyTable } from '../tableCopyAction'; import { buildDuckDBMacroDDL, escapeSQLLiteral, @@ -237,6 +238,27 @@ export const useSidebarObjectActions = ({ } }; + const handleCopyTable = (node: any) => { + const conn = node?.dataRef; + const tableName = String(conn?.tableName || node?.title || '').trim(); + if (!conn || !tableName) return; + if (!getDataSourceCapabilities(conn.config).supportsCopyTable) { + message.warning(t('table_copy.message.unsupported')); + return; + } + + const config = buildRuntimeConfig(conn, conn.dbName); + confirmCopyTable({ + config: buildRpcConnectionConfig(config) as any, + dbName: String(conn.dbName || ''), + sourceSchemaName: String(conn.schemaName || ''), + sourceTableName: tableName, + onSuccess: async () => { + await loadTables(getDatabaseNodeRef(conn, conn.dbName)); + }, + }); + }; + const handleCopyDatabaseName = async (node: any) => { const databaseName = resolveSidebarDatabaseNameForCopy(node); const label = t('sidebar.copy_object_name.label.database'); @@ -1354,6 +1376,7 @@ export const useSidebarObjectActions = ({ return { handleCopyStructure, + handleCopyTable, handleCopyTableName, handleCopyDatabaseName, handleExport, diff --git a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx index 22340faa..2d58fb88 100644 --- a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx +++ b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx @@ -67,6 +67,7 @@ type UseSidebarV2ActionHandlersArgs = { openTableDdlInDesigner: (node: any) => void; openTableInERView: (node: any) => void; handleCopyTableName: (node: any) => Promise; + handleCopyTable: (node: any) => void; handleCopyDatabaseName: (node: any) => Promise; handleCopyStructure: (node: any) => Promise; handleCopyTableAsInsert: (node: any) => Promise; @@ -131,6 +132,7 @@ export const useSidebarV2ActionHandlers = ({ openTableDdlInDesigner, openTableInERView, handleCopyTableName, + handleCopyTable, handleCopyDatabaseName, handleCopyStructure, handleCopyTableAsInsert, @@ -194,6 +196,9 @@ export const useSidebarV2ActionHandlers = ({ case 'copy-structure': void handleCopyStructure(node); return; + case 'copy-table': + handleCopyTable(node); + return; case 'copy-insert': void handleCopyTableAsInsert(node); return; diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx index d93c95cc..7a762c03 100644 --- a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx +++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx @@ -278,6 +278,7 @@ export const useSidebarV2ContextMenu = ({ const statsKey = getV2TableContextMenuStatsKey(node); const stats = v2TableContextMenuStats[statsKey]; const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const supportsCopyTable = getDataSourceCapabilities(node.dataRef?.config).supportsCopyTable; const supportsMessagePublish = Boolean(resolveMessagePublishTarget(node)); const isPinned = isSidebarTablePinned( pinnedSidebarTables, @@ -293,6 +294,7 @@ export const useSidebarV2ContextMenu = ({ stats={stats} isPinned={isPinned} supportsTruncate={supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver)} + supportsCopyTable={supportsCopyTable} supportsStarRocksRollup={isStarRocks} supportsMessagePublish={supportsMessagePublish} onAction={(action) => { diff --git a/frontend/src/components/tableCopyAction.test.ts b/frontend/src/components/tableCopyAction.test.ts new file mode 100644 index 00000000..9b0b72ee --- /dev/null +++ b/frontend/src/components/tableCopyAction.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setCurrentLanguage } from '../i18n'; +import { confirmCopyTable } from './tableCopyAction'; + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + hide: vi.fn(), + loading: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + copyTable: vi.fn(), +})); + +vi.mock('./common/ResizableDraggableModal', () => ({ + default: { confirm: mocks.confirm }, +})); + +vi.mock('antd', () => ({ + message: { + loading: mocks.loading, + success: mocks.success, + warning: mocks.warning, + error: mocks.error, + }, +})); + +describe('confirmCopyTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + setCurrentLanguage('zh-CN'); + mocks.loading.mockReturnValue(mocks.hide); + (globalThis as any).go = { + app: { App: { CopyTable: mocks.copyTable } }, + }; + }); + + it('confirms before copying and refreshes with the backend-generated table name', async () => { + const onSuccess = vi.fn(); + const config = { type: 'mysql' }; + mocks.copyTable.mockResolvedValue({ + success: true, + data: 'orders_copy2', + }); + + confirmCopyTable({ + config, + dbName: 'sales', + sourceSchemaName: 'reporting', + sourceTableName: 'orders', + onSuccess, + }); + + expect(mocks.confirm).toHaveBeenCalledOnce(); + const options = mocks.confirm.mock.calls[0][0]; + expect(options.title).toBe('复制整表'); + expect(options.content).toContain('orders'); + expect(options.content).toContain('orders_copy1'); + expect(options.content).toContain('外键、触发器和授权不会复制'); + + await options.onOk(); + + expect(mocks.copyTable).toHaveBeenCalledWith(config, 'sales', 'reporting', 'orders'); + expect(mocks.success).toHaveBeenCalledWith('整表复制成功:orders_copy2'); + expect(onSuccess).toHaveBeenCalledWith('orders_copy2'); + expect(mocks.hide).toHaveBeenCalledOnce(); + }); + + it('keeps the confirmation open and reports backend failures', async () => { + mocks.copyTable.mockResolvedValue({ + success: false, + message: 'copy failed', + }); + + confirmCopyTable({ + config: { type: 'mysql' }, + dbName: 'sales', + sourceTableName: 'orders', + }); + + const options = mocks.confirm.mock.calls[0][0]; + await expect(options.onOk()).rejects.toThrow('copy failed'); + expect(mocks.error).toHaveBeenCalledWith('整表复制失败:copy failed'); + expect(mocks.success).not.toHaveBeenCalled(); + expect(mocks.hide).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/tableCopyAction.ts b/frontend/src/components/tableCopyAction.ts new file mode 100644 index 00000000..e6180955 --- /dev/null +++ b/frontend/src/components/tableCopyAction.ts @@ -0,0 +1,92 @@ +import { message } from 'antd'; + +import { t } from '../i18n'; +import Modal from './common/ResizableDraggableModal'; + +type CopyTableResult = { + success?: boolean; + data?: unknown; + message?: string; +}; + +type CopyTableBackend = ( + config: unknown, + dbName: string, + sourceSchemaName: string, + sourceTableName: string, +) => Promise; + +type ConfirmCopyTableOptions = { + config: unknown; + dbName: string; + sourceSchemaName?: string; + sourceTableName: string; + onSuccess?: (targetTableName: string) => void | Promise; +}; + +const resolveCopyTableBackend = (): CopyTableBackend | null => { + const runtime = globalThis as typeof globalThis & { + go?: { app?: { App?: { CopyTable?: CopyTableBackend } } }; + }; + return typeof runtime.go?.app?.App?.CopyTable === 'function' + ? runtime.go.app.App.CopyTable + : null; +}; + +export const confirmCopyTable = ({ + config, + dbName, + sourceSchemaName = '', + sourceTableName, + onSuccess, +}: ConfirmCopyTableOptions): void => { + const source = String(sourceTableName || '').trim(); + if (!source) return; + + Modal.confirm({ + title: t('table_copy.modal.title'), + content: t('table_copy.modal.content', { source, target: `${source}_copy1` }), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: async () => { + const copyTable = resolveCopyTableBackend(); + if (!copyTable) { + const error = t('table_copy.message.backend_unavailable'); + message.error(error); + return Promise.reject(new Error(error)); + } + + const hide = message.loading(t('table_copy.message.loading', { source }), 0); + try { + const result = await copyTable(config, dbName, sourceSchemaName, source); + if (!result?.success) { + throw new Error(result?.message || t('common.unknown')); + } + + const target = String(result.data || '').trim(); + if (!target) { + throw new Error(t('table_copy.message.target_missing')); + } + + message.success(t('table_copy.message.success', { target })); + if (onSuccess) { + try { + await onSuccess(target); + } catch (error: any) { + message.warning(t('table_copy.message.refresh_failed', { + target, + error: error?.message || String(error), + })); + } + } + } catch (error: any) { + message.error(t('table_copy.message.failed', { + error: error?.message || String(error), + })); + return Promise.reject(error); + } finally { + hide(); + } + }, + }); +}; diff --git a/frontend/src/utils/dataSourceCapabilities.test.ts b/frontend/src/utils/dataSourceCapabilities.test.ts index d9a2ff4d..98aef7a5 100644 --- a/frontend/src/utils/dataSourceCapabilities.test.ts +++ b/frontend/src/utils/dataSourceCapabilities.test.ts @@ -37,12 +37,68 @@ describe('dataSourceCapabilities', () => { supportsQueryEditor: true, supportsExplainDiagnosis: true, supportsSqlQueryExport: true, + supportsCopyTable: false, supportsCreateDatabase: true, supportsDropDatabase: true, forceReadOnlyQueryResult: true, }); }); + it('only enables whole-table copy for backend-supported SQL families', () => { + [ + { type: 'mysql' }, + { type: 'goldendb' }, + { type: 'mariadb' }, + { type: 'oceanbase', oceanBaseProtocol: 'mysql' as const }, + { type: 'postgresql' }, + ].forEach((config) => { + expect(getDataSourceCapabilities(config).supportsCopyTable, JSON.stringify(config)).toBe(true); + }); + + [ + { type: 'oceanbase', oceanBaseProtocol: 'oracle' as const }, + { type: 'custom', driver: 'mysql' }, + { type: 'custom', driver: 'greatdb' }, + { type: 'custom', driver: 'pgx' }, + { type: 'custom', driver: 'oceanbase', oceanBaseProtocol: 'mysql' as const }, + { type: 'custom', driver: 'doris' }, + { type: 'starrocks' }, + { type: 'kingbase8' }, + { type: 'highgo' }, + { type: 'vastbase' }, + { type: 'custom', driver: 'open-gauss' }, + { type: 'custom', driver: 'gauss-db' }, + { type: 'sqlite' }, + { type: 'duckdb' }, + { type: 'sqlserver' }, + { type: 'oracle' }, + { type: 'clickhouse' }, + { type: 'mongodb' }, + { type: 'redis' }, + ].forEach((config) => { + expect(getDataSourceCapabilities(config).supportsCopyTable, JSON.stringify(config)).toBe(false); + }); + }); + + it('blocks whole-table copy when data import or structure editing is protected', () => { + expect(getDataSourceCapabilities({ + type: 'postgres', + protection: { restrictDataImport: true }, + }).supportsCopyTable).toBe(false); + expect(getDataSourceCapabilities({ + type: 'postgres', + protection: { restrictStructureEdit: true }, + }).supportsCopyTable).toBe(false); + expect(getDataSourceCapabilities({ type: 'postgres', readOnly: true }).supportsCopyTable).toBe(false); + }); + + it('keeps whole-table copy available when only row editing is protected', () => { + expect(getDataSourceCapabilities({ + type: 'postgres', + protection: { restrictDataEdit: true }, + }).supportsCopyTable).toBe(true); + }); + it('only enables execution-plan diagnosis for backend-supported SQL dialects', () => { expect(getDataSourceCapabilities({ type: 'goldendb' }).supportsExplainDiagnosis).toBe(true); expect(getDataSourceCapabilities({ type: 'custom', driver: 'greatdb' }).supportsExplainDiagnosis).toBe(true); diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index b203995e..8c209fe8 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -1,5 +1,6 @@ import type { ConnectionConfig } from '../types'; import { + isConnectionDataImportRestricted, isConnectionDataEditRestricted, isConnectionStructureEditRestricted, } from './connectionReadOnly'; @@ -162,6 +163,14 @@ const COPY_INSERT_TYPES = new Set([ 'trino', ]); +const COPY_TABLE_TYPES = new Set([ + 'mysql', + 'goldendb', + 'mariadb', + 'oceanbase', + 'postgres', +]); + const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); const EXPLAIN_DIAGNOSIS_TYPES = new Set([ 'mysql', @@ -194,6 +203,7 @@ export type DataSourceCapabilities = { supportsExplainDiagnosis: boolean; supportsSqlQueryExport: boolean; supportsCopyInsert: boolean; + supportsCopyTable: boolean; supportsCreateDatabase: boolean; supportsRenameDatabase: boolean; supportsDropDatabase: boolean; @@ -252,7 +262,9 @@ const DROP_DATABASE_TYPES = new Set([ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => { const type = resolveDataSourceType(config); + const customConnection = normalizeDataSourceToken(String(config?.type || '')) === 'custom'; const dataEditRestricted = isConnectionDataEditRestricted(config); + const dataImportRestricted = isConnectionDataImportRestricted(config); const structureEditRestricted = isConnectionStructureEditRestricted(config); return { type, @@ -260,6 +272,11 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap supportsExplainDiagnosis: EXPLAIN_DIAGNOSIS_TYPES.has(type), supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_TYPES.has(type), + supportsCopyTable: + !customConnection && + !dataImportRestricted && + !structureEditRestricted && + COPY_TABLE_TYPES.has(type), supportsCreateDatabase: !structureEditRestricted && CREATE_DATABASE_TYPES.has(type), supportsRenameDatabase: !structureEditRestricted && RENAME_DATABASE_TYPES.has(type), supportsDropDatabase: !structureEditRestricted && DROP_DATABASE_TYPES.has(type), diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 6d3f8a0b..371adfa1 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -36,6 +36,8 @@ export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; +export function CopyTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; export function CreateSQLDirectory(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index cd1ba4f8..76906867 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -58,6 +58,10 @@ export function ConfigureGlobalProxy(arg1, arg2) { return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2); } +export function CopyTable(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['CopyTable'](arg1, arg2, arg3, arg4); +} + export function CreateDatabase(arg1, arg2) { return window['go']['app']['App']['CreateDatabase'](arg1, arg2); } diff --git a/internal/app/connection_readonly.go b/internal/app/connection_readonly.go index 483d9cc0..af31ff30 100644 --- a/internal/app/connection_readonly.go +++ b/internal/app/connection_readonly.go @@ -139,6 +139,8 @@ var readOnlyConnectionActionTextKeys = map[string]string{ "connection.backend.action.drop_database": "connection.backend.action.drop_database", "重命名表": "connection.backend.action.rename_table", "connection.backend.action.rename_table": "connection.backend.action.rename_table", + "复制整表": "connection.backend.action.copy_table", + "connection.backend.action.copy_table": "connection.backend.action.copy_table", "删除表": "connection.backend.action.drop_table", "connection.backend.action.drop_table": "connection.backend.action.drop_table", "删除视图": "connection.backend.action.drop_view", diff --git a/internal/app/connection_readonly_i18n_test.go b/internal/app/connection_readonly_i18n_test.go index dbf20aa6..8a73315e 100644 --- a/internal/app/connection_readonly_i18n_test.go +++ b/internal/app/connection_readonly_i18n_test.go @@ -59,6 +59,7 @@ func TestConnectionReadOnlyCatalogKeysExist(t *testing.T) { "connection.backend.action.rename_database", "connection.backend.action.drop_database", "connection.backend.action.rename_table", + "connection.backend.action.copy_table", "connection.backend.action.drop_table", "connection.backend.action.drop_view", "connection.backend.action.drop_function_or_procedure", diff --git a/internal/app/methods_table_copy.go b/internal/app/methods_table_copy.go new file mode 100644 index 00000000..1769037e --- /dev/null +++ b/internal/app/methods_table_copy.go @@ -0,0 +1,691 @@ +package app + +import ( + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" +) + +const maxCopyTableCreateAttempts = 1000 + +type copyTablePlan struct { + createSQL string + insertSQL string + dropSQL string + postStatements []copyTablePostStatement +} + +type copyTablePostStatement struct { + sql string + createdSequenceDropSQL string +} + +type copyTableColumnMetadata struct { + writableColumns []string + serialColumns []string + identityColumns []string + sequenceOptions map[string]postgresCopyTableSequenceOptions +} + +type postgresCopyTableSequenceOptions struct { + dataType string + start int64 + increment int64 + min int64 + max int64 + cache int64 + cycle bool +} + +var errCopyTableColumnsMissing = errors.New("copy table column metadata is empty") + +// CopyTable creates a same-schema table copy and fills it with all source rows. +// The target name starts at _copy1 and advances when that name exists. +func (a *App) CopyTable(config connection.ConnectionConfig, dbName string, sourceSchemaName string, sourceTableName string) (result connection.QueryResult) { + auditSQL := fmt.Sprintf("COPY TABLE %s", strings.TrimSpace(sourceTableName)) + defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)() + + sourceTableName = strings.TrimSpace(sourceTableName) + if sourceTableName == "" { + return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.table_name_required", nil)} + } + if err := ensureConnectionAllowsStructureEdit(config, "connection.backend.action.copy_table"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := ensureConnectionAllowsDataImport(config, "connection.backend.action.copy_table"); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + dbType := resolveDDLDBType(config) + if strings.EqualFold(strings.TrimSpace(config.Type), "custom") { + dbType = "custom" + } + if !supportsCopyTableDBType(dbType) { + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_unsupported", map[string]any{ + "source": sourceTableName, + "dbType": dbType, + }), + } + } + + schemaName, sourceName := normalizeCopyTableSource(dbType, dbName, sourceSchemaName, sourceTableName) + if sourceName == "" { + return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.table_name_required", nil)} + } + + runConfig := buildRunConfigForDDL(config, dbType, dbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if safetyErr := ensureCopyTableSourceIsIndependent(dbInst, dbType, schemaName, sourceName); safetyErr != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_unsafe_storage", map[string]any{ + "source": sourceTableName, + "detail": safetyErr.Error(), + }), + } + } + + columnMetadata, columnsErr := resolveCopyTableColumnMetadata(dbInst, dbType, schemaName, sourceName) + if columnsErr != nil { + targetName := buildCopyTableTargetName(dbType, sourceName, 1) + errorMessage := columnsErr.Error() + if errors.Is(columnsErr, errCopyTableColumnsMissing) { + errorMessage = a.appText("db.backend.error.table_columns_missing_for_ddl", nil) + } + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": errorMessage, + }), + } + } + var ( + targetName string + plan copyTablePlan + ) + for attempt := 0; attempt < maxCopyTableCreateAttempts; attempt++ { + targetName = buildCopyTableTargetName(dbType, sourceName, attempt+1) + plan = buildCopyTablePlan(dbType, schemaName, sourceName, targetName, columnMetadata) + auditStatements := []string{plan.createSQL, plan.insertSQL} + for _, statement := range plan.postStatements { + auditStatements = append(auditStatements, statement.sql) + } + auditSQL = strings.Join(auditStatements, ";\n") + + if _, createErr := dbInst.Exec(plan.createSQL); createErr != nil { + if isCopyTableAlreadyExistsError(createErr) { + continue + } + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": createErr.Error(), + }), + } + } + + if _, insertErr := dbInst.Exec(plan.insertSQL); insertErr != nil { + if cleanupErr := cleanupFailedCopyTable(dbInst, plan.dropSQL, nil); cleanupErr != nil { + logger.Warnf("CopyTable 数据复制失败且清理目标表失败:source=%s target=%s copyErr=%v cleanupErr=%v", sourceTableName, targetName, insertErr, cleanupErr) + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_cleanup_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": fmt.Sprintf("%v; %v", insertErr, cleanupErr), + }), + } + } + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_data_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": insertErr.Error(), + }), + } + } + + createdSequenceDropSQLs := make([]string, 0, len(columnMetadata.serialColumns)) + for _, statement := range plan.postStatements { + if _, postErr := dbInst.Exec(statement.sql); postErr != nil { + cleanupErr := cleanupFailedCopyTable(dbInst, plan.dropSQL, createdSequenceDropSQLs) + if cleanupErr != nil { + logger.Warnf("CopyTable 完成复制状态失败且清理目标表失败:source=%s target=%s copyErr=%v cleanupErr=%v", sourceTableName, targetName, postErr, cleanupErr) + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_cleanup_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": fmt.Sprintf("%v; %v", postErr, cleanupErr), + }), + } + } + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_data_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": postErr.Error(), + }), + } + } + if statement.createdSequenceDropSQL != "" { + createdSequenceDropSQLs = append(createdSequenceDropSQLs, statement.createdSequenceDropSQL) + } + } + + return connection.QueryResult{ + Success: true, + Message: a.appText("db.backend.message.table_copied", map[string]any{ + "source": sourceTableName, + "target": targetName, + }), + Data: targetName, + } + } + + return connection.QueryResult{ + Success: false, + Message: a.appText("db.backend.error.table_copy_create_failed", map[string]any{ + "source": sourceTableName, + "target": targetName, + "error": "too many concurrent target-name conflicts", + }), + } +} + +func supportsCopyTableDBType(dbType string) bool { + switch dbType { + case "mysql", "mariadb", "oceanbase", "postgres": + return true + default: + return false + } +} + +func normalizeCopyTableSource(dbType string, dbName string, sourceSchemaName string, sourceTableName string) (string, string) { + databaseName := strings.TrimSpace(dbName) + schemaName := strings.TrimSpace(sourceSchemaName) + sourceName := strings.TrimSpace(sourceTableName) + switch dbType { + case "mysql", "mariadb", "oceanbase": + return databaseName, sourceName + case "postgres": + if schemaName == "" { + return normalizeSchemaAndTableByType(dbType, databaseName, sourceName) + } + if parsedSchema, parsedTable := db.SplitSQLQualifiedName(sourceName); parsedSchema == schemaName && parsedTable != "" { + return schemaName, parsedTable + } + if prefix := schemaName + "."; strings.HasPrefix(sourceName, prefix) { + return schemaName, strings.TrimPrefix(sourceName, prefix) + } + return schemaName, sourceName + default: + return normalizeSchemaAndTableByType(dbType, databaseName, sourceName) + } +} + +func buildCopyTablePlan(dbType string, schemaName string, sourceName string, targetName string, metadata copyTableColumnMetadata) copyTablePlan { + sourceTable := quoteTableIdentByType(dbType, schemaName, sourceName) + targetTable := quoteTableIdentByType(dbType, schemaName, targetName) + columnClause, selectClause := buildCopyTableColumnClauses(dbType, metadata.writableColumns) + + plan := copyTablePlan{ + insertSQL: fmt.Sprintf("INSERT INTO %s%s SELECT %s FROM %s", targetTable, columnClause, selectClause, sourceTable), + dropSQL: fmt.Sprintf("DROP TABLE %s", targetTable), + } + switch dbType { + case "postgres": + plan.createSQL = fmt.Sprintf("CREATE TABLE %s (LIKE %s INCLUDING ALL)", targetTable, sourceTable) + plan.insertSQL = fmt.Sprintf("INSERT INTO %s%s OVERRIDING SYSTEM VALUE SELECT %s FROM %s", targetTable, columnClause, selectClause, sourceTable) + plan.postStatements = buildPostgresCopyTablePostStatements(schemaName, targetName, metadata) + default: + plan.createSQL = fmt.Sprintf("CREATE TABLE %s LIKE %s", targetTable, sourceTable) + } + return plan +} + +func buildCopyTableColumnClauses(dbType string, columns []string) (string, string) { + if len(columns) == 0 { + return "", "*" + } + quoted := make([]string, 0, len(columns)) + for _, column := range columns { + if name := strings.TrimSpace(column); name != "" { + quoted = append(quoted, quoteIdentByType(dbType, name)) + } + } + if len(quoted) == 0 { + return "", "*" + } + joined := strings.Join(quoted, ", ") + return " (" + joined + ")", joined +} + +func resolveCopyTableColumnMetadata(dbInst db.Database, dbType string, schemaName string, sourceName string) (copyTableColumnMetadata, error) { + var metadata copyTableColumnMetadata + metadataTableName := sourceName + if dbType == "mysql" || dbType == "mariadb" || dbType == "oceanbase" { + metadataTableName = quoteIdentByType(dbType, sourceName) + } + columns, err := dbInst.GetColumns(schemaName, metadataTableName) + if err != nil { + return metadata, err + } + if len(columns) == 0 { + return metadata, errCopyTableColumnsMissing + } + traits, traitsErr := resolvePostgresCopyTableColumnTraits(dbInst, dbType, schemaName, sourceName) + if traitsErr != nil { + return metadata, traitsErr + } + metadata.writableColumns = make([]string, 0, len(columns)) + for _, column := range columns { + name := strings.TrimSpace(column.Name) + if name == "" || isGeneratedCopyTableColumn(column.Extra) { + continue + } + trait := traits[name] + if trait.generated { + continue + } + metadata.writableColumns = append(metadata.writableColumns, name) + if dbType != "postgres" { + continue + } + if trait.identity { + metadata.identityColumns = append(metadata.identityColumns, name) + continue + } + if column.Default != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(*column.Default)), "nextval(") { + metadata.serialColumns = append(metadata.serialColumns, name) + } + } + if len(metadata.writableColumns) == 0 { + return copyTableColumnMetadata{}, errCopyTableColumnsMissing + } + if dbType == "postgres" { + metadata.sequenceOptions = make(map[string]postgresCopyTableSequenceOptions, len(metadata.serialColumns)+len(metadata.identityColumns)) + sequenceColumns := append(append([]string{}, metadata.serialColumns...), metadata.identityColumns...) + for _, columnName := range sequenceColumns { + options, sequenceErr := resolvePostgresCopyTableSequenceOptions(dbInst, schemaName, sourceName, columnName) + if sequenceErr != nil { + return copyTableColumnMetadata{}, sequenceErr + } + metadata.sequenceOptions[columnName] = options + } + } + return metadata, nil +} + +type postgresCopyTableColumnTrait struct { + generated bool + identity bool +} + +func resolvePostgresCopyTableColumnTraits(dbInst db.Database, dbType string, schemaName string, sourceName string) (map[string]postgresCopyTableColumnTrait, error) { + if dbType != "postgres" { + return nil, nil + } + + traits := map[string]postgresCopyTableColumnTrait{} + query := fmt.Sprintf(` + SELECT a.attname AS column_name, + COALESCE(pg_catalog.to_jsonb(a)->>'attgenerated', '') AS generated_kind, + COALESCE(pg_catalog.to_jsonb(a)->>'attidentity', '') AS identity_kind + FROM pg_catalog.pg_attribute a + JOIN pg_catalog.pg_class c ON c.oid = a.attrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = %s + AND c.relname = %s + AND a.attnum > 0 + AND NOT a.attisdropped`, postgresCopyTableSQLLiteral(schemaName), postgresCopyTableSQLLiteral(sourceName)) + rows, _, err := dbInst.Query(query) + if err != nil { + return nil, err + } + for _, row := range rows { + name := copyTableColumnNameFromRow(row) + if name == "" { + continue + } + trait := postgresCopyTableColumnTrait{ + generated: copyTableRowString(row, "generated_kind") != "", + identity: copyTableRowString(row, "identity_kind") != "", + } + if trait.generated || trait.identity { + traits[name] = trait + } + } + return traits, nil +} + +func resolvePostgresCopyTableSequenceOptions(dbInst db.Database, schemaName string, sourceName string, columnName string) (postgresCopyTableSequenceOptions, error) { + var options postgresCopyTableSequenceOptions + sourceTable := quoteTableIdentByType("postgres", schemaName, sourceName) + query := fmt.Sprintf(` +SELECT pg_catalog.format_type(s.seqtypid, NULL) AS data_type, + s.seqstart, + s.seqincrement, + s.seqmin, + s.seqmax, + s.seqcache, + s.seqcycle +FROM pg_catalog.pg_sequence s +WHERE s.seqrelid = pg_catalog.pg_get_serial_sequence(%s, %s)::regclass`, + postgresCopyTableSQLLiteral(sourceTable), + postgresCopyTableSQLLiteral(columnName), + ) + rows, _, err := dbInst.Query(query) + if err != nil { + return options, err + } + if len(rows) != 1 { + return options, fmt.Errorf("sequence metadata not found for column %s", columnName) + } + options.dataType = strings.ToLower(copyTableRowString(rows[0], "data_type")) + switch options.dataType { + case "smallint", "integer", "bigint": + default: + return postgresCopyTableSequenceOptions{}, fmt.Errorf("unsupported sequence data type %q for column %s", options.dataType, columnName) + } + var parseErr error + if options.start, parseErr = copyTableRowInt64(rows[0], "seqstart"); parseErr != nil { + return postgresCopyTableSequenceOptions{}, parseErr + } + if options.increment, parseErr = copyTableRowInt64(rows[0], "seqincrement"); parseErr != nil || options.increment == 0 { + if parseErr == nil { + parseErr = errors.New("sequence increment cannot be zero") + } + return postgresCopyTableSequenceOptions{}, parseErr + } + if options.min, parseErr = copyTableRowInt64(rows[0], "seqmin"); parseErr != nil { + return postgresCopyTableSequenceOptions{}, parseErr + } + if options.max, parseErr = copyTableRowInt64(rows[0], "seqmax"); parseErr != nil { + return postgresCopyTableSequenceOptions{}, parseErr + } + if options.cache, parseErr = copyTableRowInt64(rows[0], "seqcache"); parseErr != nil || options.cache <= 0 { + if parseErr == nil { + parseErr = errors.New("sequence cache must be positive") + } + return postgresCopyTableSequenceOptions{}, parseErr + } + options.cycle = copyTableRowBool(rows[0], "seqcycle") + return options, nil +} + +func isGeneratedCopyTableColumn(extra string) bool { + normalized := strings.ToLower(strings.Join(strings.Fields(extra), " ")) + return strings.Contains(normalized, "virtual generated") || + strings.Contains(normalized, "stored generated") || + normalized == "generated" || + normalized == "materialized" || + normalized == "alias" +} + +func ensureCopyTableSourceIsIndependent(dbInst db.Database, dbType string, schemaName string, sourceName string) error { + switch dbType { + case "mysql", "mariadb", "oceanbase": + query := fmt.Sprintf( + "SELECT ENGINE AS engine FROM information_schema.tables WHERE HEX(TABLE_SCHEMA) = '%s' AND HEX(TABLE_NAME) = '%s' AND TABLE_TYPE = 'BASE TABLE' LIMIT 1", + mysqlCopyTableMetadataHex(schemaName), + mysqlCopyTableMetadataHex(sourceName), + ) + rows, _, err := dbInst.Query(query) + if err != nil { + return fmt.Errorf("storage metadata: %w", err) + } + engine := "" + if len(rows) > 0 { + engine = strings.ToUpper(copyTableRowString(rows[0], "engine")) + } + if !isIndependentMySQLCopyTableEngine(engine) { + if engine == "" { + engine = "" + } + return fmt.Errorf("ENGINE=%s", engine) + } + return nil + case "postgres": + query := fmt.Sprintf(` +SELECT c.relkind AS relation_kind, + c.relpersistence AS persistence, + c.relrowsecurity AS row_security +FROM pg_catalog.pg_class c +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = %s + AND c.relname = %s +LIMIT 1`, postgresCopyTableSQLLiteral(schemaName), postgresCopyTableSQLLiteral(sourceName)) + rows, _, err := dbInst.Query(query) + if err != nil { + return fmt.Errorf("storage metadata: %w", err) + } + if len(rows) == 0 { + return errors.New("relation metadata not found") + } + relationKind := copyTableRowString(rows[0], "relation_kind") + if relationKind != "r" { + return fmt.Errorf("relation_kind=%s", relationKind) + } + persistence := copyTableRowString(rows[0], "persistence") + if persistence != "" && persistence != "p" { + return fmt.Errorf("persistence=%s", persistence) + } + if copyTableRowBool(rows[0], "row_security") { + return errors.New("row_level_security=enabled") + } + return nil + default: + return nil + } +} + +func isIndependentMySQLCopyTableEngine(engine string) bool { + switch strings.ToUpper(strings.TrimSpace(engine)) { + case "INNODB", "MYISAM", "MEMORY", "ARCHIVE", "CSV", "NDB", "NDBCLUSTER", "ARIA", "ROCKSDB", "TOKUDB", "COLUMNSTORE": + return true + default: + return false + } +} + +func copyTableRowString(row map[string]interface{}, expectedKey string) string { + for key, value := range row { + if strings.EqualFold(strings.TrimSpace(key), expectedKey) && value != nil { + return strings.TrimSpace(fmt.Sprintf("%v", value)) + } + } + return "" +} + +func copyTableRowBool(row map[string]interface{}, expectedKey string) bool { + switch strings.ToLower(copyTableRowString(row, expectedKey)) { + case "1", "t", "true", "yes", "on": + return true + default: + return false + } +} + +func copyTableRowInt64(row map[string]interface{}, expectedKey string) (int64, error) { + value := copyTableRowString(row, expectedKey) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", expectedKey, value, err) + } + return parsed, nil +} + +func buildPostgresCopyTablePostStatements(schemaName string, targetName string, metadata copyTableColumnMetadata) []copyTablePostStatement { + targetTable := quoteTableIdentByType("postgres", schemaName, targetName) + statements := make([]copyTablePostStatement, 0, len(metadata.serialColumns)*4+len(metadata.identityColumns)) + for _, columnName := range metadata.serialColumns { + quotedColumn := quoteIdentByType("postgres", columnName) + options := metadata.sequenceOptions[columnName] + sequenceName := buildPostgresCopyTableSequenceName(targetName, columnName) + qualifiedSequence := quoteTableIdentByType("postgres", schemaName, sequenceName) + sequenceRegclass := fmt.Sprintf("%s::regclass", postgresCopyTableSQLLiteral(qualifiedSequence)) + cycleClause := "NO CYCLE" + if options.cycle { + cycleClause = "CYCLE" + } + statements = append(statements, + copyTablePostStatement{ + sql: fmt.Sprintf( + "CREATE SEQUENCE %s AS %s INCREMENT BY %d MINVALUE %d MAXVALUE %d START WITH %d CACHE %d %s", + qualifiedSequence, + options.dataType, + options.increment, + options.min, + options.max, + options.start, + options.cache, + cycleClause, + ), + createdSequenceDropSQL: fmt.Sprintf("DROP SEQUENCE IF EXISTS %s", qualifiedSequence), + }, + copyTablePostStatement{sql: fmt.Sprintf("ALTER SEQUENCE %s OWNED BY %s.%s", qualifiedSequence, targetTable, quotedColumn)}, + copyTablePostStatement{sql: fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT pg_catalog.nextval(%s)", targetTable, quotedColumn, sequenceRegclass)}, + copyTablePostStatement{sql: buildPostgresCopyTableSetvalSQL(sequenceRegclass, targetTable, quotedColumn, options)}, + ) + } + for _, columnName := range metadata.identityColumns { + quotedColumn := quoteIdentByType("postgres", columnName) + options := metadata.sequenceOptions[columnName] + sequenceRegclass := fmt.Sprintf( + "COALESCE(pg_catalog.pg_get_serial_sequence(%s, %s), '')::regclass", + postgresCopyTableSQLLiteral(targetTable), + postgresCopyTableSQLLiteral(columnName), + ) + statements = append(statements, copyTablePostStatement{ + sql: buildPostgresCopyTableSetvalSQL(sequenceRegclass, targetTable, quotedColumn, options), + }) + } + return statements +} + +func buildPostgresCopyTableSetvalSQL(sequenceRegclass string, targetTable string, quotedColumn string, options postgresCopyTableSequenceOptions) string { + aggregate := "pg_catalog.max" + if options.increment < 0 { + aggregate = "pg_catalog.min" + } + return fmt.Sprintf( + "SELECT pg_catalog.setval(%s, COALESCE((SELECT %s(%s) FROM %s), %d), EXISTS (SELECT 1 FROM %s))", + sequenceRegclass, + aggregate, + quotedColumn, + targetTable, + options.start, + targetTable, + ) +} + +func buildPostgresCopyTableSequenceName(targetName string, columnName string) string { + hash := sha256.Sum256([]byte(targetName + "\x00" + columnName)) + suffix := fmt.Sprintf("_copyseq_%x", hash[:5]) + prefix := strings.Trim(strings.TrimSpace(targetName)+"_"+strings.TrimSpace(columnName), "_") + return truncateUTF8Bytes(prefix, 63-len(suffix)) + suffix +} + +func copyTableColumnNameFromRow(row map[string]interface{}) string { + for key, value := range row { + if strings.EqualFold(strings.TrimSpace(key), "column_name") && value != nil { + return strings.TrimSpace(fmt.Sprintf("%v", value)) + } + } + return "" +} + +func mysqlCopyTableMetadataHex(value string) string { + return fmt.Sprintf("%X", []byte(value)) +} + +func postgresCopyTableSQLLiteral(value string) string { + for suffix := 0; ; suffix++ { + tag := fmt.Sprintf("$gonavi_copy_%d$", suffix) + if !strings.Contains(value, tag) { + return tag + value + tag + } + } +} + +func cleanupFailedCopyTable(dbInst db.Database, dropTableSQL string, createdSequenceDropSQLs []string) error { + cleanupErrors := make([]error, 0, len(createdSequenceDropSQLs)+1) + if _, err := dbInst.Exec(dropTableSQL); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + for index := len(createdSequenceDropSQLs) - 1; index >= 0; index-- { + if _, err := dbInst.Exec(createdSequenceDropSQLs[index]); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + } + return errors.Join(cleanupErrors...) +} + +func buildCopyTableTargetName(dbType string, sourceName string, suffix int) string { + suffixText := "_copy" + strconv.Itoa(suffix) + switch dbType { + case "postgres": + return truncateUTF8Bytes(sourceName, 63-len(suffixText)) + suffixText + case "mysql", "mariadb", "oceanbase": + return truncateUTF8Runes(sourceName, 64-utf8.RuneCountInString(suffixText)) + suffixText + default: + return sourceName + suffixText + } +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if maxBytes <= 0 { + return "" + } + if len(value) <= maxBytes { + return value + } + end := maxBytes + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] +} + +func truncateUTF8Runes(value string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) +} + +func isCopyTableAlreadyExistsError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "already exists") || + strings.Contains(message, "duplicate table") || + strings.Contains(message, "sqlstate 42p07") || + strings.Contains(message, "error 1050") +} diff --git a/internal/app/methods_table_copy_test.go b/internal/app/methods_table_copy_test.go new file mode 100644 index 00000000..82288efb --- /dev/null +++ b/internal/app/methods_table_copy_test.go @@ -0,0 +1,791 @@ +package app + +import ( + "errors" + "strings" + "testing" + "unicode/utf8" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/secretstore" + "GoNavi-Wails/internal/sqlaudit" +) + +type fakeCopyTableDB struct { + columns []connection.ColumnDefinition + columnsErr error + queryRows []map[string]interface{} + queryErr error + queryFunc func(string) ([]map[string]interface{}, error) + queryQueries []string + sourceEngine *string + pgSafetyRows []map[string]interface{} + sequenceRows []map[string]interface{} + execQueries []string + execFailures map[int]error +} + +func (f *fakeCopyTableDB) Connect(connection.ConnectionConfig) error { return nil } +func (f *fakeCopyTableDB) Close() error { return nil } +func (f *fakeCopyTableDB) Ping() error { return nil } + +func (f *fakeCopyTableDB) Query(query string) ([]map[string]interface{}, []string, error) { + f.queryQueries = append(f.queryQueries, query) + if strings.Contains(query, "information_schema.tables") && strings.Contains(query, "ENGINE AS engine") { + engine := "InnoDB" + if f.sourceEngine != nil { + engine = *f.sourceEngine + } + return []map[string]interface{}{{"engine": engine}}, nil, nil + } + if strings.Contains(query, "c.relkind AS relation_kind") { + if f.pgSafetyRows != nil { + return f.pgSafetyRows, nil, nil + } + return []map[string]interface{}{{ + "relation_kind": "r", + "persistence": "p", + "row_security": false, + }}, nil, nil + } + if strings.Contains(query, "FROM pg_catalog.pg_sequence") { + if f.sequenceRows != nil { + return f.sequenceRows, nil, nil + } + return []map[string]interface{}{{ + "data_type": "bigint", + "seqstart": int64(1), + "seqincrement": int64(1), + "seqmin": int64(1), + "seqmax": int64(9223372036854775807), + "seqcache": int64(1), + "seqcycle": false, + }}, nil, nil + } + if f.queryFunc != nil { + rows, err := f.queryFunc(query) + return rows, nil, err + } + return f.queryRows, nil, f.queryErr +} +func (f *fakeCopyTableDB) Exec(query string) (int64, error) { + f.execQueries = append(f.execQueries, query) + if err := f.execFailures[len(f.execQueries)]; err != nil { + return 0, err + } + return 0, nil +} +func (f *fakeCopyTableDB) GetDatabases() ([]string, error) { return nil, nil } +func (f *fakeCopyTableDB) GetTables(string) ([]string, error) { + return nil, nil +} +func (f *fakeCopyTableDB) GetCreateStatement(string, string) (string, error) { + return "", nil +} +func (f *fakeCopyTableDB) GetColumns(string, string) ([]connection.ColumnDefinition, error) { + return f.columns, f.columnsErr +} +func (f *fakeCopyTableDB) GetAllColumns(string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (f *fakeCopyTableDB) GetIndexes(string, string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (f *fakeCopyTableDB) GetForeignKeys(string, string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (f *fakeCopyTableDB) GetTriggers(string, string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +var _ db.Database = (*fakeCopyTableDB)(nil) + +func installCopyTableTestDatabase(t *testing.T, database db.Database) *App { + t.Helper() + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }) + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return config, nil + } + return NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) +} + +func TestCopyTableMySQLChoosesNextSuffixAndCopiesWritableColumns(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{ + {Name: "id", Extra: "auto_increment"}, + {Name: "name"}, + {Name: "search_text", Extra: "STORED GENERATED"}, + }, + execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if result.Data != "users_copy2" { + t.Fatalf("CopyTable target = %#v, want users_copy2", result.Data) + } + want := []string{ + "CREATE TABLE `app`.`users_copy1` LIKE `app`.`users`", + "CREATE TABLE `app`.`users_copy2` LIKE `app`.`users`", + "INSERT INTO `app`.`users_copy2` (`id`, `name`) SELECT `id`, `name` FROM `app`.`users`", + } + if len(database.execQueries) != len(want) { + t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries) + } + for index := range want { + if database.execQueries[index] != want[index] { + t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index]) + } + } +} + +func TestCopyTableMySQLKeepsDotsInsideTableName(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "audit.logs") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if result.Data != "audit.logs_copy2" { + t.Fatalf("CopyTable target = %#v, want audit.logs_copy2", result.Data) + } + want := []string{ + "CREATE TABLE `app`.`audit.logs_copy1` LIKE `app`.`audit.logs`", + "CREATE TABLE `app`.`audit.logs_copy2` LIKE `app`.`audit.logs`", + "INSERT INTO `app`.`audit.logs_copy2` (`id`) SELECT `id` FROM `app`.`audit.logs`", + } + if len(database.execQueries) != len(want) { + t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries) + } + for index := range want { + if database.execQueries[index] != want[index] { + t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index]) + } + } +} + +func TestCopyTableStopsWhenColumnMetadataIsUnavailable(t *testing.T) { + tests := []struct { + name string + columns []connection.ColumnDefinition + columnsErr error + wantDetail string + }{ + { + name: "query failed", + columnsErr: errors.New("column metadata unavailable"), + wantDetail: "column metadata unavailable", + }, + { + name: "empty metadata", + columns: []connection.ColumnDefinition{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + database := &fakeCopyTableDB{ + columns: test.columns, + columnsErr: test.columnsErr, + } + app := installCopyTableTestDatabase(t, database) + wantDetail := test.wantDetail + if test.columnsErr == nil { + wantDetail = app.appText("db.backend.error.table_columns_missing_for_ddl", nil) + } + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if wantDetail != "" && !strings.Contains(result.Message, wantDetail) { + t.Fatalf("failure message = %q, want detail %q", result.Message, wantDetail) + } + if len(database.execQueries) != 0 { + t.Fatalf("CopyTable executed SQL without column metadata: %#v", database.execQueries) + } + }) + } +} + +func TestCopyTableRejectsTableWithoutWritableColumns(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{ + {Name: "computed_value", Extra: "VIRTUAL GENERATED"}, + }, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "computed_values") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if len(database.execQueries) != 0 { + t.Fatalf("CopyTable executed SQL without writable columns: %#v", database.execQueries) + } +} + +func TestCopyTableWritesOneObjectEditorAuditEvent(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }) + database := &fakeCopyTableDB{columns: []connection.ColumnDefinition{{Name: "id"}, {Name: "name"}}} + newDatabaseFunc = func(string) (db.Database, error) { return database, nil } + resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return config, nil + } + app := newSQLAuditTestApp(t) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users") + + if !result.Success { + t.Fatalf("CopyTable result = %#v, want audited success", result) + } + events := loadSQLAuditEvents(t, app, sqlaudit.Filter{}) + if len(events) != 1 { + t.Fatalf("audit event count = %d, want 1: %#v", len(events), events) + } + event := events[0] + if event.Source != "object_editor" || event.Status != "success" || event.StatementCount != 2 { + t.Fatalf("unexpected CopyTable audit event: %#v", event) + } + if !strings.Contains(event.SQLText, "CREATE TABLE") || !strings.Contains(event.SQLText, "INSERT INTO") { + t.Fatalf("CopyTable audit SQL missing executed statements: %#v", event) + } +} + +func TestCopyTableRejectsReferenceStorageEngines(t *testing.T) { + engine := "FEDERATED" + database := &fakeCopyTableDB{sourceEngine: &engine} + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "remote_users") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded for FEDERATED") + } + if !strings.Contains(result.Message, "ENGINE=FEDERATED") { + t.Fatalf("failure message = %q, want engine detail", result.Message) + } + if len(database.execQueries) != 0 { + t.Fatalf("unsafe engine executed SQL: %#v", database.execQueries) + } +} + +func TestCopyTableRejectsPostgresPartitionedAndRLSSources(t *testing.T) { + tests := []struct { + name string + row map[string]interface{} + }{ + {name: "partitioned", row: map[string]interface{}{"relation_kind": "p", "persistence": "p", "row_security": false}}, + {name: "row security", row: map[string]interface{}{"relation_kind": "r", "persistence": "p", "row_security": true}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + database := &fakeCopyTableDB{pgSafetyRows: []map[string]interface{}{test.row}} + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if len(database.execQueries) != 0 { + t.Fatalf("unsafe PostgreSQL source executed SQL: %#v", database.execQueries) + } + }) + } +} + +func TestBuildCopyTablePlanUsesNativeDialectSyntax(t *testing.T) { + tests := []struct { + name string + dbType string + schema string + source string + target string + columns []string + wantCreate string + wantInsert string + }{ + { + name: "postgres", + dbType: "postgres", + schema: "sales", + source: "orders", + target: "orders_copy1", + columns: []string{"id", "total"}, + wantCreate: `CREATE TABLE "sales"."orders_copy1" (LIKE "sales"."orders" INCLUDING ALL)`, + wantInsert: `INSERT INTO "sales"."orders_copy1" ("id", "total") OVERRIDING SYSTEM VALUE SELECT "id", "total" FROM "sales"."orders"`, + }, + { + name: "mysql", + dbType: "mysql", + schema: "warehouse", + source: "facts", + target: "facts_copy1", + columns: []string{"id", "value"}, + wantCreate: "CREATE TABLE `warehouse`.`facts_copy1` LIKE `warehouse`.`facts`", + wantInsert: "INSERT INTO `warehouse`.`facts_copy1` (`id`, `value`) SELECT `id`, `value` FROM `warehouse`.`facts`", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := buildCopyTablePlan(test.dbType, test.schema, test.source, test.target, copyTableColumnMetadata{ + writableColumns: test.columns, + }) + if plan.createSQL != test.wantCreate { + t.Fatalf("create SQL = %q, want %q", plan.createSQL, test.wantCreate) + } + if plan.insertSQL != test.wantInsert { + t.Fatalf("insert SQL = %q, want %q", plan.insertSQL, test.wantInsert) + } + }) + } +} + +func TestCopyTablePostgresOmitsGeneratedColumns(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{ + {Name: "id"}, + {Name: "subtotal"}, + {Name: "tax"}, + {Name: "Amount"}, + {Name: "amount"}, + }, + queryFunc: func(query string) ([]map[string]interface{}, error) { + if strings.Contains(query, "generated_kind") { + return []map[string]interface{}{{"column_name": "Amount", "generated_kind": "s"}}, nil + } + return nil, nil + }, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if len(database.execQueries) != 2 { + t.Fatalf("Exec count = %d, want 2: %#v", len(database.execQueries), database.execQueries) + } + wantInsert := `INSERT INTO "sales"."orders_copy1" ("id", "subtotal", "tax", "amount") OVERRIDING SYSTEM VALUE SELECT "id", "subtotal", "tax", "amount" FROM "sales"."orders"` + if database.execQueries[1] != wantInsert { + t.Fatalf("insert SQL = %q, want %q", database.execQueries[1], wantInsert) + } +} + +func TestCopyTablePostgresRebuildsSerialAndAdvancesIdentitySequences(t *testing.T) { + serialDefault := "nextval('sales.orders_id_seq'::regclass)" + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{ + {Name: "id", Default: &serialDefault}, + {Name: "external_id"}, + {Name: "name"}, + }, + queryFunc: func(query string) ([]map[string]interface{}, error) { + if strings.Contains(query, "identity_kind") { + return []map[string]interface{}{{"column_name": "external_id", "identity_kind": "a"}}, nil + } + return nil, nil + }, + sequenceRows: []map[string]interface{}{{ + "data_type": "integer", + "seqstart": int64(100), + "seqincrement": int64(-1), + "seqmin": int64(-2147483648), + "seqmax": int64(100), + "seqcache": int64(5), + "seqcycle": true, + }}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if len(database.execQueries) != 7 { + t.Fatalf("Exec count = %d, want 7: %#v", len(database.execQueries), database.execQueries) + } + sequenceName := buildPostgresCopyTableSequenceName("orders_copy1", "id") + wantCreateSequence := `CREATE SEQUENCE "sales"."` + sequenceName + `" AS integer INCREMENT BY -1 MINVALUE -2147483648 MAXVALUE 100 START WITH 100 CACHE 5 CYCLE` + if database.execQueries[2] != wantCreateSequence { + t.Fatalf("serial sequence create SQL = %q", database.execQueries[2]) + } + if !strings.Contains(database.execQueries[4], `SET DEFAULT pg_catalog.nextval($gonavi_copy_0$"sales"."`+sequenceName+`"$gonavi_copy_0$::regclass)`) { + t.Fatalf("serial default was not rewired: %q", database.execQueries[4]) + } + if !strings.Contains(database.execQueries[6], `pg_catalog.pg_get_serial_sequence($gonavi_copy_0$"sales"."orders_copy1"$gonavi_copy_0$, $gonavi_copy_0$external_id$gonavi_copy_0$)`) { + t.Fatalf("identity sequence was not advanced: %q", database.execQueries[6]) + } + if !strings.Contains(database.execQueries[5], `pg_catalog.min("id")`) || !strings.Contains(database.execQueries[6], `pg_catalog.min("external_id")`) { + t.Fatalf("descending sequences were not calibrated with MIN: %#v", database.execQueries[5:]) + } +} + +func TestCopyTableMetadataQueriesEncodeAdversarialIdentifiers(t *testing.T) { + t.Run("mysql hex predicates", func(t *testing.T) { + database := &fakeCopyTableDB{} + schemaName := `app'; DROP TABLE audit_log; --` + tableName := "users` WHERE 1=1; --" + + if err := ensureCopyTableSourceIsIndependent(database, "mysql", schemaName, tableName); err != nil { + t.Fatalf("metadata query failed: %v", err) + } + if len(database.queryQueries) != 1 { + t.Fatalf("query count = %d, want 1", len(database.queryQueries)) + } + query := database.queryQueries[0] + if strings.Contains(query, schemaName) || strings.Contains(query, tableName) { + t.Fatalf("MySQL metadata query contains a raw identifier: %s", query) + } + for _, identifier := range []string{schemaName, tableName} { + if encoded := mysqlCopyTableMetadataHex(identifier); !strings.Contains(query, "'"+encoded+"'") { + t.Fatalf("MySQL metadata query does not contain HEX(%q): %s", identifier, query) + } + } + }) + + t.Run("postgres dollar quoted predicates", func(t *testing.T) { + database := &fakeCopyTableDB{} + schemaName := `sales$gonavi_copy_0$'; DROP SCHEMA public CASCADE; --` + tableName := `orders'; DROP TABLE audit_log; --` + + if err := ensureCopyTableSourceIsIndependent(database, "postgres", schemaName, tableName); err != nil { + t.Fatalf("metadata query failed: %v", err) + } + if len(database.queryQueries) != 1 { + t.Fatalf("query count = %d, want 1", len(database.queryQueries)) + } + query := database.queryQueries[0] + for _, identifier := range []string{schemaName, tableName} { + literal := postgresCopyTableSQLLiteral(identifier) + if !strings.Contains(query, literal) { + t.Fatalf("PostgreSQL metadata query does not contain protected literal %q: %s", literal, query) + } + tagEnd := strings.Index(literal[1:], "$") + 1 + if tagEnd <= 0 { + t.Fatalf("invalid dollar-quoted literal: %q", literal) + } + tag := literal[:tagEnd+1] + if strings.Count(literal, tag) != 2 { + t.Fatalf("dollar quote tag %q can be closed by identifier %q", tag, identifier) + } + } + }) +} + +func TestCopyTablePostgresStopsWhenColumnTraitsCannotBeVerified(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + queryFunc: func(query string) ([]map[string]interface{}, error) { + if strings.Contains(query, "identity_kind") { + return nil, errors.New("catalog unavailable") + } + return nil, nil + }, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if !strings.Contains(result.Message, "catalog unavailable") { + t.Fatalf("failure message = %q, want catalog error", result.Message) + } + if len(database.execQueries) != 0 { + t.Fatalf("CopyTable executed SQL without verified traits: %#v", database.execQueries) + } +} + +func TestCopyTablePostgresCleansCreatedSequenceWhenFinalizationFails(t *testing.T) { + serialDefault := "nextval('sales.orders_id_seq'::regclass)" + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id", Default: &serialDefault}}, + execFailures: map[int]error{ + 4: errors.New("sequence ownership failed"), + }, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if len(database.execQueries) != 6 { + t.Fatalf("Exec count = %d, want 6: %#v", len(database.execQueries), database.execQueries) + } + if database.execQueries[4] != `DROP TABLE "sales"."orders_copy1"` { + t.Fatalf("target table cleanup SQL = %q", database.execQueries[4]) + } + if !strings.HasPrefix(database.execQueries[5], `DROP SEQUENCE IF EXISTS "sales".`) { + t.Fatalf("orphan sequence cleanup SQL = %q", database.execQueries[5]) + } +} + +func TestBuildCopyTableTargetNameReservesSuffixWithinIdentifierLimit(t *testing.T) { + postgresSource := strings.Repeat("表", 21) + postgresTarget := buildCopyTableTargetName("postgres", postgresSource, 1) + if len(postgresTarget) > 63 || !utf8.ValidString(postgresTarget) || !strings.HasSuffix(postgresTarget, "_copy1") { + t.Fatalf("invalid PostgreSQL copy name %q (%d bytes)", postgresTarget, len(postgresTarget)) + } + + mysqlSource := strings.Repeat("表", 64) + mysqlTarget := buildCopyTableTargetName("mysql", mysqlSource, 1) + if utf8.RuneCountInString(mysqlTarget) > 64 || !strings.HasSuffix(mysqlTarget, "_copy1") { + t.Fatalf("invalid MySQL copy name %q (%d chars)", mysqlTarget, utf8.RuneCountInString(mysqlTarget)) + } + + longPostgresSource := strings.Repeat("x", 63) + tenthTarget := buildCopyTableTargetName("postgres", longPostgresSource, 10) + if len(tenthTarget) > 63 || !strings.HasSuffix(tenthTarget, "_copy10") { + t.Fatalf("invalid two-digit PostgreSQL copy name %q", tenthTarget) + } +} + +func TestCopyTableAlreadyExistsErrorMatchesSupportedDrivers(t *testing.T) { + tests := []struct { + message string + want bool + }{ + {message: `ERROR: relation "orders_copy1" already exists (SQLSTATE 42P07)`, want: true}, + {message: "Error 1050 (42S01): Table 'orders_copy1' already exists", want: true}, + {message: "Code: 57, table already exists", want: true}, + {message: "Code: 57, unrelated ClickHouse error", want: false}, + {message: "permission denied", want: false}, + } + for _, test := range tests { + if got := isCopyTableAlreadyExistsError(errors.New(test.message)); got != test.want { + t.Fatalf("isCopyTableAlreadyExistsError(%q) = %v, want %v", test.message, got, test.want) + } + } +} + +func TestCopyTableRetriesCreateTimeNameConflict(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if result.Data != "orders_copy2" { + t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data) + } + if len(database.execQueries) != 3 { + t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries) + } + if !strings.Contains(database.execQueries[1], `"orders_copy2"`) || !strings.Contains(database.execQueries[2], `"orders_copy2"`) { + t.Fatalf("retry did not use orders_copy2: %#v", database.execQueries) + } +} + +func TestCopyTableRetriesConflictWhenPostgresSchemaContainsDot(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "sales.region", "orders") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if result.Data != "orders_copy2" { + t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data) + } + if len(database.execQueries) != 3 { + t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries) + } + if !strings.Contains(database.execQueries[1], `"sales.region"."orders_copy2"`) { + t.Fatalf("retry did not advance inside dotted schema: %#v", database.execQueries) + } +} + +func TestCopyTableRetriesConflictWhenPostgresTableContainsDot(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders.archive") + + if !result.Success { + t.Fatalf("CopyTable returned failure: %s", result.Message) + } + if result.Data != "orders.archive_copy2" { + t.Fatalf("CopyTable target = %#v, want orders.archive_copy2", result.Data) + } + if len(database.execQueries) != 3 { + t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries) + } + if !strings.Contains(database.execQueries[1], `"public"."orders.archive_copy2"`) { + t.Fatalf("retry did not advance dotted table as one identifier: %#v", database.execQueries) + } +} + +func TestNormalizeCopyTableSourceUsesExplicitPostgresSchema(t *testing.T) { + tests := []struct { + name string + schema string + source string + wantSource string + }{ + {name: "dotted schema", schema: "sales.region", source: "sales.region.orders", wantSource: "orders"}, + {name: "qualified dotted table", schema: "public", source: "public.orders.archive", wantSource: "orders.archive"}, + {name: "unqualified dotted table", schema: "public", source: "orders.archive", wantSource: "orders.archive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotSchema, gotSource := normalizeCopyTableSource("postgres", "app", test.schema, test.source) + if gotSchema != test.schema || gotSource != test.wantSource { + t.Fatalf("normalizeCopyTableSource = (%q, %q), want (%q, %q)", gotSchema, gotSource, test.schema, test.wantSource) + } + }) + } +} + +func TestCopyTableDropsPartialTargetWhenInsertFails(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{2: errors.New("copy rows failed")}, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if !strings.Contains(result.Message, "copy rows failed") { + t.Fatalf("failure message does not retain insert error: %q", result.Message) + } + if len(database.execQueries) != 3 || database.execQueries[2] != "DROP TABLE `app`.`users_copy1`" { + t.Fatalf("partial target cleanup = %#v, want DROP TABLE users_copy1", database.execQueries) + } +} + +func TestCopyTableReportsInsertAndCleanupFailures(t *testing.T) { + database := &fakeCopyTableDB{ + columns: []connection.ColumnDefinition{{Name: "id"}}, + execFailures: map[int]error{ + 2: errors.New("copy rows failed"), + 3: errors.New("cleanup failed"), + }, + } + app := installCopyTableTestDatabase(t, database) + + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if !strings.Contains(result.Message, "copy rows failed") || !strings.Contains(result.Message, "cleanup failed") { + t.Fatalf("failure message = %q, want both errors", result.Message) + } +} + +func TestCopyTableProtectionBlocksBeforeOpeningDatabase(t *testing.T) { + tests := []struct { + name string + protection connection.ConnectionProtectionConfig + }{ + {name: "structure", protection: connection.ConnectionProtectionConfig{RestrictStructureEdit: true}}, + {name: "import", protection: connection.ConnectionProtectionConfig{RestrictDataImport: true}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + opened := false + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + newDatabaseFunc = func(string) (db.Database, error) { + opened = true + return &fakeCopyTableDB{}, nil + } + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Protection: test.protection}, "app", "app", "users") + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if opened { + t.Fatal("CopyTable opened a database despite connection protection") + } + }) + } +} + +func TestCopyTableRejectsUnsupportedDatabaseWithoutOpeningConnection(t *testing.T) { + for _, dbType := range []string{"oracle", "clickhouse", "diros", "starrocks", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb"} { + t.Run(dbType, func(t *testing.T) { + opened := false + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + newDatabaseFunc = func(string) (db.Database, error) { + opened = true + return &fakeCopyTableDB{}, nil + } + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + + result := app.CopyTable(connection.ConnectionConfig{Type: dbType}, "SYSTEM", "", "USERS") + + if result.Success { + t.Fatal("CopyTable unexpectedly succeeded") + } + if opened { + t.Fatal("CopyTable opened a database for an unsupported dialect") + } + }) + } + + t.Run("custom OceanBase Oracle", func(t *testing.T) { + opened := false + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc }) + newDatabaseFunc = func(string) (db.Database, error) { + opened = true + return &fakeCopyTableDB{}, nil + } + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.CopyTable(connection.ConnectionConfig{ + Type: "custom", + Driver: "oceanbase", + OceanBaseProtocol: "oracle", + }, "SYSTEM", "", "USERS") + if result.Success || opened { + t.Fatalf("custom OceanBase Oracle result=%#v opened=%v, want unsupported without connection", result, opened) + } + }) +} diff --git a/internal/db/mariadb_impl.go b/internal/db/mariadb_impl.go index a01bfd98..2d6424a8 100644 --- a/internal/db/mariadb_impl.go +++ b/internal/db/mariadb_impl.go @@ -231,12 +231,7 @@ func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) { } func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName) - if dbName == "" { - query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName) - } - - data, _, err := m.Query(query) + data, _, err := m.Query(buildMySQLShowFullColumnsQuery(dbName, tableName)) if err != nil { return nil, err } diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index 17f6ee5e..db8d0373 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -1071,6 +1071,10 @@ func buildMySQLShowCreateTableQuery(dbName, tableName string) string { return "SHOW CREATE TABLE " + mysqlQualifiedTableIdentifier(dbName, tableName) } +func buildMySQLShowFullColumnsQuery(dbName, tableName string) string { + return "SHOW FULL COLUMNS FROM " + mysqlQualifiedTableIdentifier(dbName, tableName) +} + func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) { data, _, err := m.Query(buildMySQLShowCreateTableQuery(dbName, tableName)) if err != nil { @@ -1086,12 +1090,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) { } func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName) - if dbName == "" { - query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName) - } - - data, _, err := m.Query(query) + data, _, err := m.Query(buildMySQLShowFullColumnsQuery(dbName, tableName)) if err != nil { return nil, err } diff --git a/internal/db/mysql_metadata_test.go b/internal/db/mysql_metadata_test.go index 8765fcba..70fd6bc6 100644 --- a/internal/db/mysql_metadata_test.go +++ b/internal/db/mysql_metadata_test.go @@ -193,3 +193,52 @@ func TestBuildMySQLShowCreateTableQueryNormalizesQuotedIdentifiers(t *testing.T) }) } } + +func TestBuildMySQLShowFullColumnsQueryEscapesIdentifiers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dbName string + tableName string + want string + }{ + { + name: "plain qualified table", + dbName: "app", + tableName: "users", + want: "SHOW FULL COLUMNS FROM `app`.`users`", + }, + { + name: "backticks cannot terminate identifiers", + dbName: "app`prod", + tableName: "audit`log", + want: "SHOW FULL COLUMNS FROM `app``prod`.`audit``log`", + }, + { + name: "quoted qualified table overrides database", + dbName: "ignored", + tableName: `"sales.region"."daily.order"`, + want: "SHOW FULL COLUMNS FROM `sales.region`.`daily.order`", + }, + { + name: "quoted dotted table remains one identifier", + dbName: "app", + tableName: "`audit.logs`", + want: "SHOW FULL COLUMNS FROM `app`.`audit.logs`", + }, + { + name: "table without database", + tableName: "standalone", + want: "SHOW FULL COLUMNS FROM `standalone`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildMySQLShowFullColumnsQuery(tt.dbName, tt.tableName); got != tt.want { + t.Fatalf("buildMySQLShowFullColumnsQuery(%q,%q)=%q,want=%q", tt.dbName, tt.tableName, got, tt.want) + } + }) + } +} diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index a2058d9f..3e931b71 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3123,6 +3123,7 @@ "common.warning": "Warnung", "connection.backend.action.apply_result_changes": "Ergebnisänderungen übernehmen", "connection.backend.action.clear_table": "Tabellendaten leeren", + "connection.backend.action.copy_table": "Gesamte Tabelle kopieren", "connection.backend.action.create_database": "Datenbank erstellen", "connection.backend.action.create_schema": "Schema erstellen", "connection.backend.action.data_sync_structure": "Struktur des Synchronisationsziels ändern", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite erfordert einen lokalen Datenbankdateipfad; die aktuelle Eingabe sieht wie eine Hostadresse aus: {{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "Die abgerufenen Spaltendefinitionen waren leer, daher konnte die CREATE TABLE-Anweisung nicht erzeugt werden", "db.backend.error.table_columns_missing_for_ddl": "Es konnten keine Spaltendefinitionen abgerufen werden, daher konnte die CREATE TABLE-Anweisung nicht erzeugt werden", + "db.backend.error.table_copy_cleanup_failed": "Die Zieltabelle \"{{target}}\" konnte nach dem fehlgeschlagenen Kopieren nicht bereinigt werden: {{error}}", + "db.backend.error.table_copy_create_failed": "Die Zieltabelle \"{{target}}\" konnte nicht aus \"{{source}}\" erstellt werden: {{error}}", + "db.backend.error.table_copy_data_failed": "Die Daten konnten nicht von \"{{source}}\" nach \"{{target}}\" kopiert werden: {{error}}", + "db.backend.error.table_copy_list_failed": "Für die Tabelle \"{{source}}\" konnte kein verfügbarer Kopiename ermittelt werden: {{error}}", + "db.backend.error.table_copy_unsupported": "Diese Datenquelle ({{dbType}}) unterstützt das Kopieren einer gesamten Tabelle nicht", + "db.backend.error.table_copy_unsafe_storage": "Die Tabelle \"{{source}}\" kann mit ihrer aktuellen Speicherdefinition nicht sicher kopiert werden: {{detail}}", "db.backend.error.table_drop_unsupported": "Die aktuelle Datenquelle ({{dbType}}) unterstützt das Löschen von Tabellen nicht", "db.backend.error.table_name_required": "Tabellenname ist erforderlich", "db.backend.error.table_new_name_no_qualifier": "Der neue Tabellenname darf kein Schema- oder Datenbankpräfix enthalten", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "Schema erstellt", "db.backend.message.schema_dropped": "Schema gelöscht", "db.backend.message.schema_renamed": "Schema umbenannt", + "db.backend.message.table_copied": "Tabelle \"{{source}}\" wurde als \"{{target}}\" kopiert", "db.backend.message.table_dropped": "Tabelle gelöscht", "db.backend.message.table_renamed": "Tabelle umbenannt", "db.backend.message.transaction_committed": "Transaktion übernommen", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "Indexspalten auswählen; die Auswahlreihenfolge wird verwendet", "table_designer.placeholder.index_name": "Indexname, z. B. idx_user_name", "table_designer.placeholder.local_columns": "Lokale Spalten auswählen; die Reihenfolge muss zu den Referenzspalten passen", + "table_copy.action.label": "Gesamte Tabelle kopieren", + "table_copy.message.backend_unavailable": "Diese Version unterstützt das Kopieren gesamter Tabellen nicht. Bitte aktualisieren und erneut versuchen.", + "table_copy.message.failed": "Die gesamte Tabelle konnte nicht kopiert werden: {{error}}", + "table_copy.message.loading": "Die gesamte Tabelle \"{{source}}\" wird kopiert...", + "table_copy.message.refresh_failed": "Die Tabelle wurde als \"{{target}}\" kopiert, aber die Tabellenliste konnte nicht aktualisiert werden: {{error}}", + "table_copy.message.success": "Tabelle erfolgreich kopiert: {{target}}", + "table_copy.message.target_missing": "Das Backend hat den neuen Tabellennamen nicht zurückgegeben", + "table_copy.message.unsupported": "Diese Verbindung unterstützt das Kopieren einer gesamten Tabelle nicht.", + "table_copy.modal.content": "Spalten, Indizes, Standardwerte und alle Daten aus \"{{source}}\" kopieren. Die Benennung beginnt mit \"{{target}}\" und wird bei Konflikten hochgezählt. Fremdschlüssel, Trigger und Berechtigungen werden nicht kopiert. Fortfahren?", + "table_copy.modal.title": "Gesamte Tabelle kopieren", "table_designer.placeholder.primary_index_name": "Primärschlüsselindex verwendet festen Namen: PRIMARY", "table_designer.placeholder.ref_columns": "Referenzspalten eingeben; mehrere Werte möglich", "table_designer.placeholder.ref_table": "Referenztabelle; db.table wird unterstützt", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index ee8fb3bf..aa2e9864 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3123,6 +3123,7 @@ "common.warning": "Warning", "connection.backend.action.apply_result_changes": "Apply result changes", "connection.backend.action.clear_table": "Clear table data", + "connection.backend.action.copy_table": "Copy entire table", "connection.backend.action.create_database": "Create database", "connection.backend.action.create_schema": "Create schema", "connection.backend.action.data_sync_structure": "Modify sync target structure", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite requires a local database file path; the current input looks like a host address: {{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "The retrieved column definitions were empty, so the CREATE TABLE statement could not be generated", "db.backend.error.table_columns_missing_for_ddl": "No column definitions were retrieved, so the CREATE TABLE statement could not be generated", + "db.backend.error.table_copy_cleanup_failed": "Failed to clean up target table \"{{target}}\" after the copy failed: {{error}}", + "db.backend.error.table_copy_create_failed": "Failed to create target table \"{{target}}\" from \"{{source}}\": {{error}}", + "db.backend.error.table_copy_data_failed": "Failed to copy data from \"{{source}}\" to \"{{target}}\": {{error}}", + "db.backend.error.table_copy_list_failed": "Failed to find an available copy name for table \"{{source}}\": {{error}}", + "db.backend.error.table_copy_unsupported": "This data source ({{dbType}}) does not support copying an entire table", + "db.backend.error.table_copy_unsafe_storage": "Table \"{{source}}\" cannot be copied safely with its current storage definition: {{detail}}", "db.backend.error.table_drop_unsupported": "The current data source ({{dbType}}) does not support dropping tables", "db.backend.error.table_name_required": "Table name is required", "db.backend.error.table_new_name_no_qualifier": "The new table name must not include a schema or database prefix", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "Schema created", "db.backend.message.schema_dropped": "Schema dropped", "db.backend.message.schema_renamed": "Schema renamed", + "db.backend.message.table_copied": "Copied table \"{{source}}\" to \"{{target}}\"", "db.backend.message.table_dropped": "Table dropped", "db.backend.message.table_renamed": "Table renamed", "db.backend.message.transaction_committed": "Transaction committed", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "Select index columns; selection order is used", "table_designer.placeholder.index_name": "Index name, for example idx_user_name", "table_designer.placeholder.local_columns": "Select local fields; order must match referenced fields", + "table_copy.action.label": "Copy entire table", + "table_copy.message.backend_unavailable": "This version does not support copying entire tables. Update and try again.", + "table_copy.message.failed": "Failed to copy entire table: {{error}}", + "table_copy.message.loading": "Copying entire table \"{{source}}\"...", + "table_copy.message.refresh_failed": "The table was copied to \"{{target}}\", but the table list could not be refreshed: {{error}}", + "table_copy.message.success": "Table copied successfully: {{target}}", + "table_copy.message.target_missing": "The backend did not return the new table name", + "table_copy.message.unsupported": "This connection does not support copying an entire table.", + "table_copy.modal.content": "Copy columns, indexes, defaults, and all data from \"{{source}}\". Naming starts at \"{{target}}\" and increments on conflicts. Foreign keys, triggers, and grants are not copied. Continue?", + "table_copy.modal.title": "Copy entire table", "table_designer.placeholder.primary_index_name": "Primary key index uses fixed name: PRIMARY", "table_designer.placeholder.ref_columns": "Enter referenced fields; multiple values supported", "table_designer.placeholder.ref_table": "Referenced table; db.table is supported", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 17541593..118681a8 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3123,6 +3123,7 @@ "common.warning": "警告", "connection.backend.action.apply_result_changes": "結果セットの変更を適用", "connection.backend.action.clear_table": "テーブルデータを削除", + "connection.backend.action.copy_table": "テーブル全体をコピー", "connection.backend.action.create_database": "データベースを作成", "connection.backend.action.create_schema": "スキーマを作成", "connection.backend.action.data_sync_structure": "同期先の構造変更", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite にはローカルデータベースファイルのパスが必要です。現在の入力はホストアドレスのようです: {{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "列定義が空のため、CREATE TABLE 文を生成できません", "db.backend.error.table_columns_missing_for_ddl": "列定義を取得できないため、CREATE TABLE 文を生成できません", + "db.backend.error.table_copy_cleanup_failed": "コピー失敗後に対象テーブル「{{target}}」をクリーンアップできませんでした: {{error}}", + "db.backend.error.table_copy_create_failed": "「{{source}}」から対象テーブル「{{target}}」を作成できませんでした: {{error}}", + "db.backend.error.table_copy_data_failed": "「{{source}}」から「{{target}}」へデータをコピーできませんでした: {{error}}", + "db.backend.error.table_copy_list_failed": "テーブル「{{source}}」の使用可能なコピー名を取得できませんでした: {{error}}", + "db.backend.error.table_copy_unsupported": "現在のデータソース({{dbType}})はテーブル全体のコピーに対応していません", + "db.backend.error.table_copy_unsafe_storage": "テーブル「{{source}}」は現在のストレージ定義では安全にコピーできません: {{detail}}", "db.backend.error.table_drop_unsupported": "現在のデータソース({{dbType}})はテーブルの削除をサポートしていません", "db.backend.error.table_name_required": "テーブル名は必須です", "db.backend.error.table_new_name_no_qualifier": "新しいテーブル名に schema またはデータベース接頭辞を含めることはできません", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "スキーマを作成しました", "db.backend.message.schema_dropped": "スキーマを削除しました", "db.backend.message.schema_renamed": "スキーマ名を変更しました", + "db.backend.message.table_copied": "テーブル「{{source}}」を「{{target}}」としてコピーしました", "db.backend.message.table_dropped": "テーブルを削除しました", "db.backend.message.table_renamed": "テーブル名を変更しました", "db.backend.message.transaction_committed": "トランザクションをコミットしました", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "インデックス列を選択してください。選択順が使われます", "table_designer.placeholder.index_name": "インデックス名(例: idx_user_name)", "table_designer.placeholder.local_columns": "ローカル列を選択してください。順序は参照列と一致させてください", + "table_copy.action.label": "テーブル全体をコピー", + "table_copy.message.backend_unavailable": "このバージョンはテーブル全体のコピーに対応していません。更新して再試行してください。", + "table_copy.message.failed": "テーブル全体のコピーに失敗しました: {{error}}", + "table_copy.message.loading": "テーブル「{{source}}」全体をコピーしています...", + "table_copy.message.refresh_failed": "テーブルを「{{target}}」としてコピーしましたが、テーブル一覧を更新できませんでした: {{error}}", + "table_copy.message.success": "テーブル全体をコピーしました: {{target}}", + "table_copy.message.target_missing": "バックエンドから新しいテーブル名が返されませんでした", + "table_copy.message.unsupported": "この接続はテーブル全体のコピーに対応していません。", + "table_copy.modal.content": "「{{source}}」の列、インデックス、デフォルト値、および全データをコピーします。名前は「{{target}}」から始まり、競合時は連番になります。外部キー、トリガー、権限はコピーされません。続行しますか?", + "table_copy.modal.title": "テーブル全体をコピー", "table_designer.placeholder.primary_index_name": "主キーインデックスの固定名: PRIMARY", "table_designer.placeholder.ref_columns": "参照列を入力してください。複数指定できます", "table_designer.placeholder.ref_table": "参照テーブル。db.table 形式を使用できます", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index c78550a1..a00cadca 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3123,6 +3123,7 @@ "common.warning": "Предупреждение", "connection.backend.action.apply_result_changes": "Применить изменения результата", "connection.backend.action.clear_table": "Очистить данные таблицы", + "connection.backend.action.copy_table": "Копировать всю таблицу", "connection.backend.action.create_database": "Создать базу данных", "connection.backend.action.create_schema": "Создать схему", "connection.backend.action.data_sync_structure": "Изменить структуру цели синхронизации", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite требуется путь к локальному файлу базы данных; текущий ввод похож на адрес хоста: {{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "Полученные определения столбцов оказались пустыми, поэтому не удалось сформировать инструкцию CREATE TABLE", "db.backend.error.table_columns_missing_for_ddl": "Не удалось получить определения столбцов, поэтому не удалось сформировать инструкцию CREATE TABLE", + "db.backend.error.table_copy_cleanup_failed": "Не удалось удалить целевую таблицу «{{target}}» после ошибки копирования: {{error}}", + "db.backend.error.table_copy_create_failed": "Не удалось создать целевую таблицу «{{target}}» из «{{source}}»: {{error}}", + "db.backend.error.table_copy_data_failed": "Не удалось скопировать данные из «{{source}}» в «{{target}}»: {{error}}", + "db.backend.error.table_copy_list_failed": "Не удалось подобрать свободное имя копии для таблицы «{{source}}»: {{error}}", + "db.backend.error.table_copy_unsupported": "Этот источник данных ({{dbType}}) не поддерживает копирование всей таблицы", + "db.backend.error.table_copy_unsafe_storage": "Таблицу «{{source}}» нельзя безопасно скопировать с текущим определением хранилища: {{detail}}", "db.backend.error.table_drop_unsupported": "Текущий источник данных ({{dbType}}) не поддерживает удаление таблиц", "db.backend.error.table_name_required": "Имя таблицы обязательно", "db.backend.error.table_new_name_no_qualifier": "Новое имя таблицы не должно содержать префикс схемы или базы данных", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "Схема создана", "db.backend.message.schema_dropped": "Схема удалена", "db.backend.message.schema_renamed": "Схема переименована", + "db.backend.message.table_copied": "Таблица «{{source}}» скопирована как «{{target}}»", "db.backend.message.table_dropped": "Таблица удалена", "db.backend.message.table_renamed": "Таблица переименована", "db.backend.message.transaction_committed": "Транзакция зафиксирована", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "Выберите столбцы индекса; используется порядок выбора", "table_designer.placeholder.index_name": "Имя индекса, например idx_user_name", "table_designer.placeholder.local_columns": "Выберите локальные поля; порядок должен совпадать со ссылочными полями", + "table_copy.action.label": "Копировать всю таблицу", + "table_copy.message.backend_unavailable": "Эта версия не поддерживает копирование всей таблицы. Обновите приложение и повторите попытку.", + "table_copy.message.failed": "Не удалось скопировать всю таблицу: {{error}}", + "table_copy.message.loading": "Копирование всей таблицы «{{source}}»...", + "table_copy.message.refresh_failed": "Таблица скопирована как «{{target}}», но список таблиц не удалось обновить: {{error}}", + "table_copy.message.success": "Таблица успешно скопирована: {{target}}", + "table_copy.message.target_missing": "Серверная часть не вернула имя новой таблицы", + "table_copy.message.unsupported": "Это подключение не поддерживает копирование всей таблицы.", + "table_copy.modal.content": "Скопировать столбцы, индексы, значения по умолчанию и все данные из «{{source}}». Имена начинаются с «{{target}}» и увеличиваются при конфликте. Внешние ключи, триггеры и права не копируются. Продолжить?", + "table_copy.modal.title": "Копировать всю таблицу", "table_designer.placeholder.primary_index_name": "Индекс первичного ключа использует фиксированное имя: PRIMARY", "table_designer.placeholder.ref_columns": "Введите ссылочные поля; можно указать несколько", "table_designer.placeholder.ref_table": "Ссылочная таблица; поддерживается db.table", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index e8cb7850..d69de88a 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3123,6 +3123,7 @@ "common.warning": "警告", "connection.backend.action.apply_result_changes": "提交结果修改", "connection.backend.action.clear_table": "清空表数据", + "connection.backend.action.copy_table": "复制整表", "connection.backend.action.create_database": "创建数据库", "connection.backend.action.create_schema": "创建模式", "connection.backend.action.data_sync_structure": "同步目标结构", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:{{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "字段定义为空,无法生成建表语句", "db.backend.error.table_columns_missing_for_ddl": "未获取到字段定义,无法生成建表语句", + "db.backend.error.table_copy_cleanup_failed": "复制失败后清理目标表“{{target}}”失败:{{error}}", + "db.backend.error.table_copy_create_failed": "创建目标表“{{target}}”(来源“{{source}}”)失败:{{error}}", + "db.backend.error.table_copy_data_failed": "将“{{source}}”的数据复制到“{{target}}”失败:{{error}}", + "db.backend.error.table_copy_list_failed": "查找表“{{source}}”的可用副本名称失败:{{error}}", + "db.backend.error.table_copy_unsupported": "当前数据源({{dbType}})暂不支持复制整表", + "db.backend.error.table_copy_unsafe_storage": "表“{{source}}”的当前存储定义无法安全复制:{{detail}}", "db.backend.error.table_drop_unsupported": "当前数据源({{dbType}})暂不支持删除表", "db.backend.error.table_name_required": "表名不能为空", "db.backend.error.table_new_name_no_qualifier": "新表名不能包含 schema 或数据库前缀", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "模式创建成功", "db.backend.message.schema_dropped": "模式删除成功", "db.backend.message.schema_renamed": "模式重命名成功", + "db.backend.message.table_copied": "已将表“{{source}}”复制为“{{target}}”", "db.backend.message.table_dropped": "表删除成功", "db.backend.message.table_renamed": "表重命名成功", "db.backend.message.transaction_committed": "事务已提交", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "请选择索引字段(按选择顺序生效)", "table_designer.placeholder.index_name": "索引名(例如 idx_user_name)", "table_designer.placeholder.local_columns": "请选择本表字段(顺序需与参考字段一致)", + "table_copy.action.label": "复制整表", + "table_copy.message.backend_unavailable": "当前版本不支持复制整表,请更新后重试。", + "table_copy.message.failed": "整表复制失败:{{error}}", + "table_copy.message.loading": "正在复制整表“{{source}}”...", + "table_copy.message.refresh_failed": "表已复制为“{{target}}”,但刷新表列表失败:{{error}}", + "table_copy.message.success": "整表复制成功:{{target}}", + "table_copy.message.target_missing": "后端未返回新表名", + "table_copy.message.unsupported": "当前连接不支持复制整表。", + "table_copy.modal.content": "将复制表“{{source}}”的列、索引、默认值等表内结构和全部数据,新表名从“{{target}}”开始,冲突时自动递增。外键、触发器和授权不会复制。是否继续?", + "table_copy.modal.title": "复制整表", "table_designer.placeholder.primary_index_name": "主键索引固定名称:PRIMARY", "table_designer.placeholder.ref_columns": "请输入参考字段(支持多个)", "table_designer.placeholder.ref_table": "参考表(支持 db.table)", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 647fbcac..cae2924f 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3123,6 +3123,7 @@ "common.warning": "警告", "connection.backend.action.apply_result_changes": "提交結果修改", "connection.backend.action.clear_table": "清空資料表資料", + "connection.backend.action.copy_table": "複製整個資料表", "connection.backend.action.create_database": "建立資料庫", "connection.backend.action.create_schema": "建立結構描述", "connection.backend.action.data_sync_structure": "同步目標結構", @@ -4932,6 +4933,12 @@ "db.backend.error.sqlite_host_port_not_file_path": "SQLite 需要本機資料庫檔案路徑,目前輸入看起來像主機位址:{{dsn}}", "db.backend.error.table_columns_empty_for_ddl": "欄位定義為空,無法產生建表語句", "db.backend.error.table_columns_missing_for_ddl": "未取得欄位定義,無法產生建表語句", + "db.backend.error.table_copy_cleanup_failed": "複製失敗後清理目標資料表「{{target}}」失敗:{{error}}", + "db.backend.error.table_copy_create_failed": "建立目標資料表「{{target}}」(來源「{{source}}」)失敗:{{error}}", + "db.backend.error.table_copy_data_failed": "將「{{source}}」的資料複製到「{{target}}」失敗:{{error}}", + "db.backend.error.table_copy_list_failed": "尋找資料表「{{source}}」的可用副本名稱失敗:{{error}}", + "db.backend.error.table_copy_unsupported": "目前資料來源({{dbType}})暫不支援複製整個資料表", + "db.backend.error.table_copy_unsafe_storage": "資料表「{{source}}」目前的儲存定義無法安全複製:{{detail}}", "db.backend.error.table_drop_unsupported": "目前資料來源({{dbType}})暫不支援刪除資料表", "db.backend.error.table_name_required": "資料表名稱不能為空", "db.backend.error.table_new_name_no_qualifier": "新資料表名稱不能包含 schema 或資料庫前綴", @@ -4971,6 +4978,7 @@ "db.backend.message.schema_created": "模式建立成功", "db.backend.message.schema_dropped": "模式刪除成功", "db.backend.message.schema_renamed": "模式重新命名成功", + "db.backend.message.table_copied": "已將資料表「{{source}}」複製為「{{target}}」", "db.backend.message.table_dropped": "資料表刪除成功", "db.backend.message.table_renamed": "資料表重新命名成功", "db.backend.message.transaction_committed": "交易已提交", @@ -8007,6 +8015,16 @@ "table_designer.placeholder.index_columns": "請選擇索引欄位(依選取順序生效)", "table_designer.placeholder.index_name": "索引名稱(例如 idx_user_name)", "table_designer.placeholder.local_columns": "請選擇本表欄位(順序需與參照欄位一致)", + "table_copy.action.label": "複製整個資料表", + "table_copy.message.backend_unavailable": "目前版本不支援複製整個資料表,請更新後再試。", + "table_copy.message.failed": "複製整個資料表失敗:{{error}}", + "table_copy.message.loading": "正在複製整個資料表「{{source}}」...", + "table_copy.message.refresh_failed": "資料表已複製為「{{target}}」,但重新整理資料表清單失敗:{{error}}", + "table_copy.message.success": "複製整個資料表成功:{{target}}", + "table_copy.message.target_missing": "後端未傳回新資料表名稱", + "table_copy.message.unsupported": "目前連線不支援複製整個資料表。", + "table_copy.modal.content": "將複製資料表「{{source}}」的欄位、索引、預設值等表內結構與全部資料,新名稱從「{{target}}」開始,衝突時自動遞增。外鍵、觸發器與權限不會複製。是否繼續?", + "table_copy.modal.title": "複製整個資料表", "table_designer.placeholder.primary_index_name": "主鍵索引固定名稱:PRIMARY", "table_designer.placeholder.ref_columns": "請輸入參照欄位(支援多個)", "table_designer.placeholder.ref_table": "參照表(支援 db.table)",