diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 4736b792..32d9a3f3 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -318,7 +318,7 @@ export { const EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS = 80; const DataGrid: React.FC = ({ - data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, ddlDbName, ddlTableName, connectionId, pkColumns = [], editLocator, readOnly = false, + data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, schemaName, ddlDbName, ddlTableName, connectionId, pkColumns = [], editLocator, readOnly = false, resultSql, resultExportAllSql, onReload, onSort, onPageChange, onLastPage, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, @@ -5448,6 +5448,7 @@ const DataGrid: React.FC = ({ dataPanelOriginalRef, dataPanelValue, dbName, + schemaName, dbType, ddlLoading, ddlModalOpen, diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index 04a089dd..b9abc13e 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -1337,6 +1337,7 @@ interface DataGridProps { resultSql?: string; resultExportAllSql?: string; dbName?: string; + schemaName?: string; /** DDL 查询使用的数据库/命名空间;查询结果页不复用列元数据目标。 */ ddlDbName?: string; /** DDL 查询使用的表名;查询结果页仅在该目标明确时显示 DDL 入口。 */ diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index e00d3494..342c11c3 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -121,6 +121,7 @@ const DataGridShell: React.FC = (props) => { dataPanelOriginalRef, dataPanelValue, dbName, + schemaName, dbType, ddlLoading, ddlModalOpen, @@ -780,6 +781,7 @@ const renderDataTableView = () => ( connectionId: String(connectionId || ''), dbName, tableName, + schemaName, initialTab: 'columns', readOnly: designerReadOnly, objectType: 'table', diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 9e5cc211..fcc7fbbf 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -1380,6 +1380,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ objectType={tab.objectType || 'table'} exportScope="table" dbName={tab.dbName} + schemaName={tab.schemaName} connectionId={tab.connectionId} pkColumns={pkColumns} editLocator={editLocator} diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index dc149dab..3e70cfa4 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -1673,6 +1673,14 @@ describe('Sidebar locate toolbar', () => { expect(contextMenuSource).toContain('isPinned={isPinned}'); }); + it('preserves schema context when opening table designer tabs', () => { + const source = readSourceFile('./Sidebar.tsx'); + + expect(source).toMatch(/const openDesign = \(node: any,[\s\S]*?schemaName[\s\S]*?type: 'design',[\s\S]*?schemaName,/); + expect(source).toMatch(/const openNewTableDesign = \(node: any\)[\s\S]*?schemaName[\s\S]*?type: 'design',[\s\S]*?schemaName,/); + expect(source).toContain("design-${id}-${dbName}-${schemaName || 'default'}-${tableName}"); + }); + it('moves pinned databases first while preserving loaded database children', () => { const pinnedSidebarDatabases = [ buildSidebarDatabasePinKey('conn-1', 'analytics'), diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index cc4d6c4c..1616b630 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1770,13 +1770,13 @@ const Sidebar: React.FC<{ }; const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => { - const { tableName, dbName, id } = node.dataRef; + const { tableName, dbName, id, schemaName } = node.dataRef; const conn = connections.find(c => c.id === id); const forceReadOnly = readOnly || isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config); addTab({ - id: `design-${id}-${dbName}-${tableName}`, + id: `design-${id}-${dbName}-${schemaName || 'default'}-${tableName}`, title: forceReadOnly ? t('sidebar.tab.table_structure', { table: tableName }) : t('sidebar.tab.design_table', { table: tableName }), @@ -1784,13 +1784,14 @@ const Sidebar: React.FC<{ connectionId: id, dbName: dbName, tableName: tableName, + schemaName, initialTab: initialTab, readOnly: forceReadOnly }); }; const openNewTableDesign = (node: any) => { - const { dbName, id } = node.dataRef; + const { dbName, id, schemaName } = node.dataRef; const conn = connections.find(c => c.id === id); if (isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config)) { message.warning(t('sidebar.message.visual_new_table_unsupported')); @@ -1803,6 +1804,7 @@ const Sidebar: React.FC<{ connectionId: id, dbName: dbName, tableName: '', // Empty tableName signals creation mode + schemaName, initialTab: 'columns', readOnly: false }); diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index ee7d13f7..d8ba075e 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,7 +1,7 @@ import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; import { Table, Tabs, Button, message, Input, Checkbox, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio, Spin } from 'antd'; -import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined } from '@ant-design/icons'; +import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined, FolderOpenOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -37,6 +37,16 @@ import { resolveSqlDialect, } from '../utils/sqlDialect'; import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName'; +import { loadSchemas } from './sidebar/sidebarMetadataLoaders'; +import { + qualifyTableDesignerCreateName, + extractTableDesignerCurrentSchema, + resolveLoadedTableDesignerSchema, + resolveTableDesignerEditTarget, + resolveTableDesignerSchema, + supportsTableDesignerSchemaSelection as supportsRequestedTableDesignerSchemaSelection, + TABLE_DESIGNER_CURRENT_SCHEMA_SQL, +} from './tableDesignerSchemaContext'; import { buildTDengineStableOptions, buildTDengineStableQueries } from '../utils/tdengineStableMetadata'; import { cloneTableDesignerColumnsForPaste, @@ -444,6 +454,13 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em // New Table State const [newTableName, setNewTableName] = useState(''); + const [schemaOptions, setSchemaOptions] = useState<{ label: string; value: string }[]>([]); + const [selectedSchema, setSelectedSchema] = useState(() => stripIdentifierQuotes( + splitQualifiedNameLast(tab.tableName || '').parentPath || tab.schemaName || '', + )); + const [schemaSelectionOverride, setSchemaSelectionOverride] = useState(false); + const [schemaReady, setSchemaReady] = useState(false); + const [schemaLoading, setSchemaLoading] = useState(false); const [charset, setCharset] = useState('utf8mb4'); const [collation, setCollation] = useState('utf8mb4_unicode_ci'); const [starRocksTableKind, setStarRocksTableKind] = useState('olap'); @@ -528,6 +545,8 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em const connections = useStore(state => state.connections); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); + const tableDesignerSchemaByConnection = useStore(state => state.tableDesignerSchemaByConnection || {}); + const setTableDesignerSchema = useStore(state => state.setTableDesignerSchema); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const i18nLanguage = useTableDesignerI18nLanguage(); @@ -535,8 +554,13 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em const isV2Ui = appearance.uiVersion === 'v2'; const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff'; const readOnly = !!tab.readOnly; - const designerTableTitle = tab.tableName || newTableName || t('table_designer.title.untitled_table', undefined, i18nLanguage); + const designerTableTitle = isNewTable + ? (newTableName || t('table_designer.title.untitled_table', undefined, i18nLanguage)) + : (splitQualifiedNameLast(tab.tableName || '').objectName || tab.tableName || t('table_designer.title.untitled_table', undefined, i18nLanguage)); const designerDbTitle = tab.dbName || t('table_designer.title.default_database', undefined, i18nLanguage); + const designerSchemaTitle = ( + isNewTable ? stripIdentifierQuotes(splitQualifiedNameLast(newTableName).parentPath) : '' + ) || selectedSchema || tab.schemaName || ''; const designerColumnSummary = t('table_designer.summary.columns', { count: columns.length }, i18nLanguage); const metadataLoading = columnsLoading || indexesLoading || foreignKeysLoading || triggersLoading || ddlLoading; const charsetOptions = useMemo(() => getCharsetOptions(i18nLanguage), [i18nLanguage]); @@ -554,8 +578,15 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em const pendingFocusColumnKeyRef = useRef(null); const focusHighlightTimerRef = useRef(null); const metadataLoadSeqRef = useRef(0); + const schemaLoadSeqRef = useRef(0); + const latestSelectedSchemaRef = useRef(selectedSchema); + const schemaContextKeyRef = useRef(''); const [focusColumnKey, setFocusColumnKey] = useState(''); + useEffect(() => { + latestSelectedSchemaRef.current = selectedSchema; + }, [selectedSchema]); + const openCommentEditor = useCallback((record: EditableColumn) => { if (!record?._key) return; setInlineCommentEditingKey(''); @@ -1012,10 +1043,14 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em }; const rpcConfig = buildRpcConnectionConfig(config) as any; - const dbName = tab.dbName || ''; - const tableName = tab.tableName || ''; + const dbName = tab.dbName || ''; + const tableInfo = resolveTableInfo(); + const resolvedTableName = supportsRequestedTableDesignerSchemaSelection(tableInfo.dbType) + ? tableInfo.qualifiedName + : (tab.tableName || ''); + const tableName = resolvedTableName || tab.tableName || ''; - setColumnsLoading(true); + setColumnsLoading(true); setIndexesLoading(true); setForeignKeysLoading(true); setTriggersLoading(true); @@ -1115,7 +1150,7 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em useEffect(() => { fetchData(); - }, [tab]); + }, [tab, selectedSchema]); // --- Trigger Handlers --- @@ -1156,9 +1191,143 @@ const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, em }); }; + const supportsTableDesignerSchemaSelection = supportsRequestedTableDesignerSchemaSelection(getDbType()); + + useEffect(() => { + if (!supportsTableDesignerSchemaSelection) { + schemaLoadSeqRef.current += 1; + schemaContextKeyRef.current = ''; + latestSelectedSchemaRef.current = ''; + setSchemaOptions([]); + setSelectedSchema(''); + setSchemaSelectionOverride(false); + setSchemaReady(true); + setSchemaLoading(false); + return; + } + + const conn = connections.find(c => c.id === tab.connectionId); + const dbName = String(tab.dbName || '').trim(); + if (!conn || !dbName) { + setSchemaReady(false); + return; + } + + const requestSeq = schemaLoadSeqRef.current + 1; + schemaLoadSeqRef.current = requestSeq; + const explicitSchema = stripIdentifierQuotes( + splitQualifiedNameLast(tab.tableName || '').parentPath || tab.schemaName || '', + ); + const rememberedSchema = tableDesignerSchemaByConnection[tab.connectionId] || ''; + const contextKey = [tab.connectionId, dbName, tab.tableName || 'new'].join('::'); + if (schemaContextKeyRef.current !== contextKey) { + schemaContextKeyRef.current = contextKey; + latestSelectedSchemaRef.current = explicitSchema; + setSelectedSchema(explicitSchema); + setSchemaSelectionOverride(false); + setSchemaOptions(explicitSchema ? [{ label: explicitSchema, value: explicitSchema }] : []); + } + + let cancelled = false; + setSchemaReady(false); + setSchemaLoading(true); + const loadCurrentSchema = DBQuery( + buildRpcConnectionConfig({ + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || '', + database: conn.config.database || '', + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, + }) as any, + dbName, + TABLE_DESIGNER_CURRENT_SCHEMA_SQL, + ).then(result => { + if (!result.success) return ''; + return extractTableDesignerCurrentSchema(result.data); + }).catch(() => ''); + + void Promise.all([loadSchemas(conn, dbName), loadCurrentSchema]) + .then(([result, currentSchema]) => { + if (cancelled) return; + const schemaNames = Array.from(new Map( + (Array.isArray(result.schemas) ? result.schemas : []) + .map(schema => String(schema || '').trim()) + .filter(Boolean) + .map(schema => [schema.toLocaleLowerCase(), schema] as const), + ).values()); + const resolved = resolveLoadedTableDesignerSchema({ + requestSeq, + currentRequestSeq: schemaLoadSeqRef.current, + latestSelectedSchema: latestSelectedSchemaRef.current, + explicitSchema, + rememberedSchema, + currentSchema, + schemaNames, + }); + if (!resolved) return; + latestSelectedSchemaRef.current = resolved.selectedSchema; + setSelectedSchema(resolved.selectedSchema); + setSchemaOptions(resolved.schemaNames.map(schema => ({ label: schema, value: schema }))); + if (resolved.selectedSchema) { + setTableDesignerSchema?.(tab.connectionId, resolved.selectedSchema); + } + }) + .catch(() => { + if (cancelled || requestSeq !== schemaLoadSeqRef.current) return; + const fallback = latestSelectedSchemaRef.current || explicitSchema; + setSelectedSchema(fallback); + setSchemaOptions(fallback ? [{ label: fallback, value: fallback }] : []); + }) + .finally(() => { + if (!cancelled && requestSeq === schemaLoadSeqRef.current) { + setSchemaReady(true); + setSchemaLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [connections, supportsTableDesignerSchemaSelection, tab.connectionId, tab.dbName, tab.schemaName, tab.tableName]); + + const handleSchemaChange = (schemaName: string) => { + const nextSchema = String(schemaName || '').trim(); + if (!nextSchema || nextSchema === selectedSchema) return; + const applySchema = () => { + if (!isNewTable) { + setColumns([]); + setOriginalColumns([]); + setIndexes([]); + setFks([]); + setTriggers([]); + setDdl(''); + setSelectedColumnRowKeys([]); + } + latestSelectedSchemaRef.current = nextSchema; + setSelectedSchema(nextSchema); + setSchemaSelectionOverride(!isNewTable); + setTableDesignerSchema?.(tab.connectionId, nextSchema); + }; + if (hasUnsavedDraftChanges) { + Modal.confirm({ + title: t('table_designer.modal.unsaved_changes_title', undefined, i18nLanguage), + icon: , + content: t('table_designer.modal.unsaved_changes_content', undefined, i18nLanguage), + okText: t('table_designer.action.refresh_anyway', undefined, i18nLanguage), + cancelText: t('table_designer.action.cancel', undefined, i18nLanguage), + onOk: applySchema, + }); + return; + } + applySchema(); + }; + const generateTriggerTemplate = (): string => { const dbType = getDbType(); - const tblName = tab.tableName || 'table_name'; + const tblName = supportsRequestedTableDesignerSchemaSelection(dbType) + ? resolveTableInfo().qualifiedName + : (tab.tableName || 'table_name'); switch (dbType) { case 'mysql': @@ -1177,7 +1346,8 @@ END;`; case 'highgo': case 'vastbase': case 'opengauss': - case 'gaussdb': + case 'gaussdb': { + const tableRef = quoteIdentifierPathByDialect(tblName, dbType); return `CREATE OR REPLACE FUNCTION trigger_function_name() RETURNS TRIGGER AS $$ BEGIN @@ -1187,9 +1357,10 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_name -BEFORE INSERT ON "${tblName}" +BEFORE INSERT ON ${tableRef} FOR EACH ROW EXECUTE FUNCTION trigger_function_name();`; + } case 'sqlserver': return `CREATE TRIGGER trigger_name ON [${tblName}] @@ -1222,7 +1393,9 @@ END;`; const buildDropTriggerSql = (triggerName: string): string => { const dbType = getDbType(); - const tblName = tab.tableName || ''; + const tblName = supportsRequestedTableDesignerSchemaSelection(dbType) + ? resolveTableInfo().qualifiedName + : (tab.tableName || ''); switch (dbType) { case 'mysql': @@ -1237,7 +1410,7 @@ END;`; case 'vastbase': case 'opengauss': case 'gaussdb': - return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`; + return `DROP TRIGGER IF EXISTS ${quoteIdentifierPartByDialect(triggerName, dbType)} ON ${quoteIdentifierPathByDialect(tblName, dbType)}`; case 'sqlserver': return `DROP TRIGGER IF EXISTS [${triggerName}]`; case 'oracle': @@ -1260,7 +1433,9 @@ END;`; const handleEditTrigger = () => { if (!selectedTrigger) return; const dbType = getDbType(); - const tblName = tab.tableName || ''; + const tblName = supportsRequestedTableDesignerSchemaSelection(dbType) + ? resolveTableInfo().qualifiedName + : (tab.tableName || ''); let createSql = ''; if (dbType === 'mysql') { @@ -1894,31 +2069,17 @@ ${selectedTrigger.statement}`; const resolveTableInfo = () => { const dbType = getDbType(); - const rawTable = String(tab.tableName || '').trim(); - const rawDb = String(tab.dbName || '').trim(); - const parsed = splitQualifiedName(rawTable); - const table = parsed.objectName || stripIdentifierQuotes(rawTable); - let schema = parsed.schemaName; - - if (!schema) { - if (isPgLikeDialect(dbType)) { - schema = rawDb || 'public'; - } else if (isSqlServerDialect(dbType)) { - schema = 'dbo'; - } else if (isOracleLikeDialect(dbType)) { - schema = rawDb; - } else { - schema = rawDb; - } - } - - const qualifiedName = schema ? `${schema}.${table}` : table; + const resolved = resolveTableDesignerEditTarget({ + dbType, + dbName: String(tab.dbName || ''), + tableName: String(tab.tableName || ''), + selectedSchema, + schemaSelectionOverride, + }); return { dbType, - schema: stripIdentifierQuotes(schema), - table: stripIdentifierQuotes(table), - qualifiedName, - tableRef: quoteIdentifierPathByDialect(qualifiedName, dbType), + ...resolved, + tableRef: quoteIdentifierPathByDialect(resolved.qualifiedName, dbType), }; }; @@ -1933,7 +2094,7 @@ ${selectedTrigger.statement}`; originalColumns, columns, }); - }, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]); + }, [columns, connections, isNewTable, originalColumns, readOnly, schemaSelectionOverride, selectedSchema, tab.connectionId, tab.dbName, tab.tableName]); const supportsIndexSchemaOps = (): boolean => { const dbType = getDbType(); @@ -2013,9 +2174,10 @@ ${selectedTrigger.statement}`; }; const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { + const dbType = getDbType(); return buildCreateTablePreviewSql({ - dbType: getDbType(), - tableName: targetTableName, + dbType, + tableName: qualifyTableDesignerCreateName(targetTableName, selectedSchema, dbType), columns: targetColumns, charset: targetCharset, collation: targetCollation, @@ -2067,7 +2229,11 @@ ${selectedTrigger.statement}`; const approved = await confirmProductionRisk({ connection: conn, action: t('connection.production_risk.action.execute_sql'), - target: [tab.dbName, copyTableName.trim()].filter(Boolean).join(' / '), + target: [ + tab.dbName, + supportsTableDesignerSchemaSelection ? designerSchemaTitle : '', + copyTableName.trim(), + ].filter(Boolean).join(' / '), translate: (key, params) => t(key, params, i18nLanguage), }); if (!approved) return; @@ -2111,7 +2277,7 @@ ${selectedTrigger.statement}`; const approved = await confirmProductionRisk({ connection: conn, action: t('connection.production_risk.action.execute_sql'), - target: [tab.dbName, tab.tableName].filter(Boolean).join(' / '), + target: [tab.dbName, supportsTableDesignerSchemaSelection ? designerSchemaTitle : '', tab.tableName].filter(Boolean).join(' / '), translate: (key, params) => t(key, params, i18nLanguage), }); if (!approved) { @@ -2315,13 +2481,13 @@ END;`; if (!isIndexModalOpen) return ''; const result = getIndexCreateSqlResult(indexForm); return result.sql || `-- ${result.message || 'Index CREATE SQL placeholder unavailable'}`; - }, [connections, i18nLanguage, indexForm, isIndexModalOpen, tab.connectionId, tab.dbName, tab.tableName]); + }, [connections, i18nLanguage, indexForm, isIndexModalOpen, schemaSelectionOverride, selectedSchema, tab.connectionId, tab.dbName, tab.tableName]); const selectedIndexCreateSql = useMemo(() => { if (!selectedIndex || selectedIndexKeys.length !== 1) return ''; const result = getIndexCreateSqlResult(buildIndexFormFromRow(selectedIndex)); return result.sql || `-- ${result.message || 'Index CREATE SQL unavailable'}`; - }, [connections, i18nLanguage, selectedIndex, selectedIndexKeys.length, tab.connectionId, tab.dbName, tab.tableName]); + }, [connections, i18nLanguage, schemaSelectionOverride, selectedIndex, selectedIndexKeys.length, selectedSchema, tab.connectionId, tab.dbName, tab.tableName]); const indexTableHeight = selectedIndexCreateSql ? Math.max(180, tableHeight - 220) : tableHeight; @@ -2765,7 +2931,12 @@ END;`; detail: { connectionId, dbName, - tableName: String(newTableName || '').trim(), + tableName: qualifyTableDesignerCreateName( + String(newTableName || '').trim(), + selectedSchema, + getDbType(), + ), + schemaName: supportsTableDesignerSchemaSelection ? designerSchemaTitle : undefined, }, })); } @@ -3630,6 +3801,7 @@ END;`;
{designerDbTitle} + {supportsTableDesignerSchemaSelection && designerSchemaTitle && {designerSchemaTitle}} {designerColumnSummary} {readOnly && {t('table_designer.status.read_only', undefined, i18nLanguage)}}
@@ -3651,13 +3823,39 @@ END;`; alignItems: 'center' }} > + {supportsTableDesignerSchemaSelection && ( + setNewTableName(e.target.value)} + value={newTableName} + onChange={e => { + const nextTableName = e.target.value; + setNewTableName(nextTableName); + const explicitSchema = resolveTableDesignerSchema(nextTableName, selectedSchema, getDbType()); + if (explicitSchema && explicitSchema !== selectedSchema) { + latestSelectedSchemaRef.current = explicitSchema; + setSelectedSchema(explicitSchema); + setTableDesignerSchema?.(tab.connectionId, explicitSchema); + } + }} style={{ width: 150 }} /> {!isTDengineNewTable && ( @@ -3683,7 +3881,7 @@ END;`; )} )} - {!readOnly && } + {!readOnly && } {!isNewTable && } {!isNewTable && !readOnly && supportsTableCommentOps() && ( diff --git a/frontend/src/components/tableDesignerSchemaContext.test.ts b/frontend/src/components/tableDesignerSchemaContext.test.ts new file mode 100644 index 00000000..90e29337 --- /dev/null +++ b/frontend/src/components/tableDesignerSchemaContext.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { + qualifyTableDesignerCreateName, + extractTableDesignerCurrentSchema, + resolveInitialTableDesignerSchema, + resolveLoadedTableDesignerSchema, + resolveTableDesignerEditTarget, + resolveTableDesignerSchema, + resolveTableDesignerTableInfo, +} from './tableDesignerSchemaContext'; + +describe('tableDesignerSchemaContext', () => { + it.each(['postgres', 'kingbase'])('qualifies bare %s create names with the selected schema', (dbType) => { + expect(qualifyTableDesignerCreateName('users', 'sales', dbType)).toBe('sales.users'); + }); + + it('keeps an explicitly qualified create name instead of replacing its schema', () => { + expect(qualifyTableDesignerCreateName('archive.users', 'sales', 'postgres')).toBe('archive.users'); + }); + + it('uses the selected schema for PostgreSQL table edits instead of the database name', () => { + expect(resolveTableDesignerTableInfo({ + dbType: 'postgres', + dbName: 'app_database', + tableName: 'users', + selectedSchema: 'sales', + })).toEqual({ + schema: 'sales', + table: 'users', + qualifiedName: 'sales.users', + }); + }); + + it('keeps an explicit edit schema instead of replacing it with the selected schema', () => { + expect(resolveTableDesignerTableInfo({ + dbType: 'postgres', + dbName: 'app_database', + tableName: 'archive.users', + selectedSchema: 'sales', + })).toEqual({ + schema: 'archive', + table: 'users', + qualifiedName: 'archive.users', + }); + }); + + it('keeps explicit edit targets until the user actively switches schemas', () => { + expect(resolveTableDesignerEditTarget({ + dbType: 'postgres', + dbName: 'app_database', + tableName: 'archive.users', + selectedSchema: 'sales', + schemaSelectionOverride: false, + }).qualifiedName).toBe('archive.users'); + expect(resolveTableDesignerEditTarget({ + dbType: 'postgres', + dbName: 'app_database', + tableName: 'archive.users', + selectedSchema: 'sales', + schemaSelectionOverride: true, + }).qualifiedName).toBe('sales.users'); + }); + + it('extracts current_schema from the metadata query result', () => { + expect(extractTableDesignerCurrentSchema([{ schema_name: 'tenant' }])).toBe('tenant'); + expect(extractTableDesignerCurrentSchema([{ current_schema: 'sales' }])).toBe('sales'); + expect(extractTableDesignerCurrentSchema([])).toBe(''); + }); + + it('resolves an explicit table schema before the remembered schema', () => { + expect(resolveTableDesignerSchema('archive.users', 'sales', 'kingbase')).toBe('archive'); + }); + + it('does not guess public when a bare table has no selected schema', () => { + expect(resolveTableDesignerSchema('users', '', 'postgres')).toBe(''); + expect(qualifyTableDesignerCreateName('users', '', 'postgres')).toBe('users'); + }); + + it('prefers explicit and valid remembered schemas before the current schema', () => { + expect(resolveInitialTableDesignerSchema({ + explicitSchema: 'archive', + rememberedSchema: 'sales', + currentSchema: 'tenant', + schemaNames: ['archive', 'sales', 'tenant'], + })).toBe('archive'); + expect(resolveInitialTableDesignerSchema({ + explicitSchema: '', + rememberedSchema: 'sales', + currentSchema: 'tenant', + schemaNames: ['sales', 'tenant'], + })).toBe('sales'); + }); + + it('falls back to current_schema when the remembered schema no longer exists', () => { + expect(resolveInitialTableDesignerSchema({ + explicitSchema: '', + rememberedSchema: 'removed', + currentSchema: 'tenant', + schemaNames: ['public', 'tenant'], + })).toBe('tenant'); + expect(resolveInitialTableDesignerSchema({ + explicitSchema: '', + rememberedSchema: 'sales', + currentSchema: '', + schemaNames: [], + })).toBe('sales'); + }); + + it('ignores stale loads and preserves a selection made while schemas are loading', () => { + expect(resolveLoadedTableDesignerSchema({ + requestSeq: 1, + currentRequestSeq: 2, + latestSelectedSchema: 'sales', + explicitSchema: '', + rememberedSchema: '', + currentSchema: 'tenant', + schemaNames: ['public', 'sales', 'tenant'], + })).toBeNull(); + expect(resolveLoadedTableDesignerSchema({ + requestSeq: 2, + currentRequestSeq: 2, + latestSelectedSchema: 'sales', + explicitSchema: '', + rememberedSchema: '', + currentSchema: 'tenant', + schemaNames: ['public', 'tenant'], + })).toEqual({ + selectedSchema: 'sales', + schemaNames: ['sales', 'public', 'tenant'], + }); + }); + + it('keeps non PostgreSQL-like table names unchanged', () => { + expect(qualifyTableDesignerCreateName('users', 'sales', 'mysql')).toBe('users'); + expect(resolveTableDesignerTableInfo({ + dbType: 'mysql', + dbName: 'app_database', + tableName: 'users', + selectedSchema: 'sales', + })).toEqual({ + schema: 'app_database', + table: 'users', + qualifiedName: 'app_database.users', + }); + }); +}); diff --git a/frontend/src/components/tableDesignerSchemaContext.ts b/frontend/src/components/tableDesignerSchemaContext.ts new file mode 100644 index 00000000..46af55a4 --- /dev/null +++ b/frontend/src/components/tableDesignerSchemaContext.ts @@ -0,0 +1,161 @@ +import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName'; +import { isOracleLikeDialect, isSqlServerDialect, resolveSqlDialect } from '../utils/sqlDialect'; + +const supportsRequestedSchemaSelection = (dbType: string): boolean => { + const dialect = resolveSqlDialect(dbType); + return dialect === 'postgres' || dialect === 'kingbase'; +}; + +interface ResolveTableDesignerTableInfoInput { + dbType: string; + dbName: string; + tableName: string; + selectedSchema?: string; +} + +interface ResolveTableDesignerEditTargetInput extends ResolveTableDesignerTableInfoInput { + schemaSelectionOverride: boolean; +} + +export const TABLE_DESIGNER_CURRENT_SCHEMA_SQL = 'SELECT current_schema() AS schema_name'; + +export const extractTableDesignerCurrentSchema = (rows: unknown): string => { + if (!Array.isArray(rows) || rows.length === 0 || !rows[0] || typeof rows[0] !== 'object') return ''; + const row = rows[0] as Record; + return String(row.schema_name ?? row.current_schema ?? Object.values(row)[0] ?? '').trim(); +}; + +export const supportsTableDesignerSchemaSelection = supportsRequestedSchemaSelection; + +export const resolveTableDesignerSchema = ( + tableName: string, + selectedSchema: string, + dbType: string, +): string => { + if (!supportsRequestedSchemaSelection(dbType)) return ''; + const parsed = splitQualifiedNameLast(tableName); + return stripIdentifierQuotes(parsed.parentPath || selectedSchema); +}; + +export const resolveInitialTableDesignerSchema = ({ + explicitSchema, + rememberedSchema, + currentSchema, + schemaNames, +}: { + explicitSchema: string; + rememberedSchema: string; + currentSchema: string; + schemaNames: string[]; +}): string => { + const explicit = stripIdentifierQuotes(explicitSchema); + if (explicit) return explicit; + + const remembered = stripIdentifierQuotes(rememberedSchema); + const normalizedSchemaNames = schemaNames.map(schema => stripIdentifierQuotes(schema)).filter(Boolean); + if ( + remembered + && normalizedSchemaNames.some(schema => schema.toLocaleLowerCase() === remembered.toLocaleLowerCase()) + ) { + return remembered; + } + return stripIdentifierQuotes(currentSchema) || (normalizedSchemaNames.length === 0 ? remembered : ''); +}; + +export const resolveLoadedTableDesignerSchema = ({ + requestSeq, + currentRequestSeq, + latestSelectedSchema, + explicitSchema, + rememberedSchema, + currentSchema, + schemaNames, +}: { + requestSeq: number; + currentRequestSeq: number; + latestSelectedSchema: string; + explicitSchema: string; + rememberedSchema: string; + currentSchema: string; + schemaNames: string[]; +}): { selectedSchema: string; schemaNames: string[] } | null => { + if (requestSeq !== currentRequestSeq) return null; + const selectedSchema = stripIdentifierQuotes(latestSelectedSchema) || resolveInitialTableDesignerSchema({ + explicitSchema, + rememberedSchema, + currentSchema, + schemaNames, + }); + const normalizedSchemaNames = schemaNames.map(schema => stripIdentifierQuotes(schema)).filter(Boolean); + if ( + selectedSchema + && !normalizedSchemaNames.some(schema => schema.toLocaleLowerCase() === selectedSchema.toLocaleLowerCase()) + ) { + normalizedSchemaNames.unshift(selectedSchema); + } + return { selectedSchema, schemaNames: normalizedSchemaNames }; +}; + +export const qualifyTableDesignerCreateName = ( + tableName: string, + selectedSchema: string, + dbType: string, +): string => { + const rawTableName = String(tableName || '').trim(); + if (!rawTableName || !supportsRequestedSchemaSelection(dbType)) return rawTableName; + if (splitQualifiedNameLast(rawTableName).parentPath) return rawTableName; + const schema = stripIdentifierQuotes(selectedSchema); + return schema ? `${schema}.${rawTableName}` : rawTableName; +}; + +export const resolveTableDesignerTableInfo = ({ + dbType, + dbName, + tableName, + selectedSchema, +}: ResolveTableDesignerTableInfoInput) => { + const dialect = resolveSqlDialect(dbType); + const rawTable = String(tableName || '').trim(); + const rawDb = String(dbName || '').trim(); + const parsed = splitQualifiedNameLast(rawTable); + const table = stripIdentifierQuotes(parsed.objectName || rawTable); + let schema = stripIdentifierQuotes(parsed.parentPath || ( + supportsRequestedSchemaSelection(dialect) ? (selectedSchema || '') : '' + )); + + if (!schema) { + if (supportsRequestedSchemaSelection(dialect)) { + schema = ''; + } else if (isSqlServerDialect(dialect)) { + schema = 'dbo'; + } else if (isOracleLikeDialect(dialect)) { + schema = stripIdentifierQuotes(rawDb); + } else { + schema = stripIdentifierQuotes(rawDb); + } + } + + return { + schema, + table, + qualifiedName: schema ? `${schema}.${table}` : table, + }; +}; + +export const resolveTableDesignerEditTarget = ({ + dbType, + dbName, + tableName, + selectedSchema, + schemaSelectionOverride, +}: ResolveTableDesignerEditTargetInput) => { + const sourceTableName = schemaSelectionOverride + ? (splitQualifiedNameLast(tableName).objectName || tableName) + : tableName; + return resolveTableDesignerTableInfo({ + dbType, + dbName, + tableName: sourceTableName, + selectedSchema, + }); +}; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index cfde252e..e10b52dc 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1449,6 +1449,45 @@ describe('store appearance persistence', () => { ]); }); + it('persists the table designer schema per connection and clears it with the connection', async () => { + const { useStore } = await importStore(); + useStore.getState().replaceConnections([{ + id: 'pg-conn', + name: 'PostgreSQL', + config: { id: 'pg-conn', type: 'postgres', host: 'localhost', port: 5432, user: 'postgres' }, + }]); + + useStore.getState().setTableDesignerSchema('pg-conn', 'sales'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(useStore.getState().tableDesignerSchemaByConnection).toEqual({ 'pg-conn': 'sales' }); + expect(JSON.parse(storage.getItem('lite-db-storage') || '{}').state.tableDesignerSchemaByConnection) + .toEqual({ 'pg-conn': 'sales' }); + + vi.resetModules(); + const reloaded = await importStore(); + expect(reloaded.useStore.getState().tableDesignerSchemaByConnection).toEqual({ 'pg-conn': 'sales' }); + + reloaded.useStore.getState().removeConnection('pg-conn'); + expect(reloaded.useStore.getState().tableDesignerSchemaByConnection).toEqual({}); + }); + + it('clears remembered table designer schemas when connections are replaced', async () => { + const { useStore } = await importStore(); + useStore.getState().replaceConnections([ + { id: 'pg-1', name: 'PG 1', config: { id: 'pg-1', type: 'postgres', host: 'one', port: 5432, user: 'postgres' } }, + { id: 'pg-2', name: 'PG 2', config: { id: 'pg-2', type: 'postgres', host: 'two', port: 5432, user: 'postgres' } }, + ]); + useStore.getState().setTableDesignerSchema('pg-1', 'sales'); + useStore.getState().setTableDesignerSchema('pg-2', 'archive'); + + useStore.getState().replaceConnections([ + { id: 'pg-2', name: 'PG 2', config: { id: 'pg-2', type: 'postgres', host: 'two', port: 5432, user: 'postgres' } }, + ]); + + expect(useStore.getState().tableDesignerSchemaByConnection).toEqual({ 'pg-2': 'archive' }); + }); + it('migrates flat v15 connection groups to explicit root child order', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 59bfd9f6..05f48a30 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1821,6 +1821,7 @@ interface AppState { tableExportHistories: Record; tableAccessCount: Record; tableSortPreference: Record; + tableDesignerSchemaByConnection: Record; tableColumnOrders: Record; enableColumnOrderMemory: boolean; /** 数据表横向滚动时左侧固定的数据列(按表维度记忆;勾选列/行号列始终固定) */ @@ -2022,6 +2023,7 @@ interface AppState { dbName: string, sortBy: "name" | "frequency", ) => void; + setTableDesignerSchema: (connectionId: string, schemaName: string) => void; setSidebarTablePinned: ( connectionId: string, dbName: string, @@ -2909,6 +2911,24 @@ const sanitizeTableSortPreference = ( return result; }; +const sanitizeTableDesignerSchemaByConnection = ( + value: unknown, +): Record => { + const raw = + value && typeof value === "object" + ? (value as Record) + : {}; + const result: Record = {}; + Object.entries(raw).forEach(([connectionId, schemaName]) => { + const safeConnectionId = toTrimmedString(connectionId); + const safeSchemaName = toTrimmedString(schemaName).slice(0, 256); + if (safeConnectionId && safeSchemaName) { + result[safeConnectionId] = safeSchemaName; + } + }); + return result; +}; + const sanitizeTableColumnOrders = ( value: unknown, ): Record => { @@ -3454,6 +3474,7 @@ const PERSISTED_STATE_DEPENDENCY_KEYS = [ "sqlSnippets", "tableAccessCount", "tableSortPreference", + "tableDesignerSchemaByConnection", "tableColumnOrders", "enableColumnOrderMemory", "tablePinnedLeftColumns", @@ -3517,6 +3538,9 @@ const buildPersistedStateProjection = ( sqlSnippets: state.sqlSnippets, tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), tableSortPreference: state.tableSortPreference, + tableDesignerSchemaByConnection: sanitizeTableDesignerSchemaByConnection( + state.tableDesignerSchemaByConnection, + ), tableColumnOrders: state.tableColumnOrders, enableColumnOrderMemory: state.enableColumnOrderMemory, tablePinnedLeftColumns: state.tablePinnedLeftColumns, @@ -3642,6 +3666,7 @@ export const useStore = create()( tableExportHistories: {}, tableAccessCount: {}, tableSortPreference: {}, + tableDesignerSchemaByConnection: {}, tableColumnOrders: {}, enableColumnOrderMemory: true, tablePinnedLeftColumns: {}, @@ -3707,6 +3732,8 @@ export const useStore = create()( ), nextConnections, ); + const nextDesignerSchemas = { ...state.tableDesignerSchemaByConnection }; + delete nextDesignerSchemas[id]; return { connections: nextConnections, connectionTags: normalized.connectionTags, @@ -3721,6 +3748,7 @@ export const useStore = create()( id, nextConnections.map((connection) => connection.id), ), + tableDesignerSchemaByConnection: nextDesignerSchemas, sidebarRootOrder: normalized.sidebarRootOrder, }; }), @@ -3732,10 +3760,16 @@ export const useStore = create()( state.sidebarRootOrder, nextConnections, ); + const validConnectionIds = new Set(nextConnections.map((connection) => connection.id)); + const nextDesignerSchemas = Object.fromEntries( + Object.entries(state.tableDesignerSchemaByConnection) + .filter(([connectionId]) => validConnectionIds.has(connectionId)), + ); return { connections: nextConnections, connectionTags: normalized.connectionTags, sidebarRootOrder: normalized.sidebarRootOrder, + tableDesignerSchemaByConnection: nextDesignerSchemas, shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions, }; @@ -5298,6 +5332,19 @@ export const useStore = create()( }; }), + setTableDesignerSchema: (connectionId, schemaName) => + set((state) => { + const safeConnectionId = toTrimmedString(connectionId); + const safeSchemaName = toTrimmedString(schemaName).slice(0, 256); + if (!safeConnectionId || !safeSchemaName) return state; + return { + tableDesignerSchemaByConnection: { + ...state.tableDesignerSchemaByConnection, + [safeConnectionId]: safeSchemaName, + }, + }; + }), + setSidebarTablePinned: (connectionId, dbName, tableName, schemaName, pinned) => set((state) => { const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName); @@ -5917,6 +5964,9 @@ export const useStore = create()( nextState.tableSortPreference = sanitizeTableSortPreference( state.tableSortPreference, ); + nextState.tableDesignerSchemaByConnection = sanitizeTableDesignerSchemaByConnection( + state.tableDesignerSchemaByConnection, + ); // 新增的列排序记忆状态不需要做版本特殊兼容,直接做基本的类型保护 const safeOrders = sanitizeTableColumnOrders(state.tableColumnOrders); nextState.tableColumnOrders = safeOrders; @@ -6022,6 +6072,9 @@ export const useStore = create()( tableSortPreference: sanitizeTableSortPreference( state.tableSortPreference, ), + tableDesignerSchemaByConnection: sanitizeTableDesignerSchemaByConnection( + state.tableDesignerSchemaByConnection, + ), tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders), enableColumnOrderMemory: state.enableColumnOrderMemory !== false, tablePinnedLeftColumns: sanitizeTableColumnOrders(