feat(table-designer): 支持选择默认 Schema (#850) (#861)

## 关联 Issue

Fixes #850

## 问题根因

表设计器只接收数据库名和表名,PostgreSQL/Kingbase 的 Schema
上下文没有从数据库树、数据视图继续传递;建表时也直接使用裸表名。因此同一数据库存在多个 Schema 时,创建和修改 SQL 会落到连接默认
Schema,无法选择目标 Schema。

## 修复方案

- 在数据库树、数据视图和表设计器入口之间传递 Schema 上下文。
- 为 PostgreSQL/Kingbase 表设计器增加 Schema 选择器,通过 Schema 列表和
`current_schema()` 初始化当前选择,并按连接记忆最近一次选择。
- 建表时使用所选 Schema 限定裸表名;改表、索引和注释操作统一解析所选 Schema,同时保留显式 `schema.table`
的优先级。
- 使用请求序号和最新选择引用避免异步元数据响应覆盖用户切换;连接被替换或删除时同步清理失效记忆。
- 增加 Schema 解析、连接记忆和入口上下文传递的回归测试。

## 验证结果

| 验证项 | 命令或步骤 | 结果 |
| --- | --- | --- |
| Schema 与入口定向回归 | `npm --prefix frontend test -- <相关测试文件>` | 通过,327/327
|
| 独立安全复核定向回归 | 3 个相关测试文件 | 通过,209/209 |
| TypeScript 检查 | `tsc --noEmit` | 通过 |
| 前端构建 | `npm --prefix frontend run build` | 通过 |
| 完整前端回归 | `npm --prefix frontend test` | 通过,447 个测试文件、3793/3793 |
| 桌面与窄屏 GUI 验收 | 浏览器操作 PostgreSQL/Kingbase 表设计器 Schema 展示、选择及上下文传递路径 |
通过,无布局重叠或控制台错误 |
| 差异检查 | `git diff --check`、`git diff --cached --check` | 通过 |

## 风险与兼容性

Schema 选择能力仅对 PostgreSQL 和 Kingbase 开启;其他数据库类型继续沿用原有数据库/Schema 解析和 UI
行为。未修改后端 API、持久化数据格式或数据库 `search_path`。当 `current_schema()`
查询失败且没有显式或已记忆 Schema 时保持裸表名,交由数据库连接默认行为处理。

## 回滚方式

回滚提交 `92b103a23ce243650025c1c047167be61394d9ed` 即可恢复原行为;本次改动不迁移或修改用户数据。
This commit is contained in:
Syngnat
2026-08-06 21:00:36 +08:00
committed by GitHub
11 changed files with 663 additions and 50 deletions

View File

@@ -318,7 +318,7 @@ export {
const EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS = 80;
const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
dataPanelOriginalRef,
dataPanelValue,
dbName,
schemaName,
dbType,
ddlLoading,
ddlModalOpen,

View File

@@ -1337,6 +1337,7 @@ interface DataGridProps {
resultSql?: string;
resultExportAllSql?: string;
dbName?: string;
schemaName?: string;
/** DDL 查询使用的数据库/命名空间;查询结果页不复用列元数据目标。 */
ddlDbName?: string;
/** DDL 查询使用的表名;查询结果页仅在该目标明确时显示 DDL 入口。 */

View File

@@ -121,6 +121,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (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',

View File

@@ -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}

View File

@@ -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'),

View File

@@ -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
});

View File

@@ -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<StarRocksTableKind>('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<string | null>(null);
const focusHighlightTimerRef = useRef<number | null>(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: <ExclamationCircleOutlined />,
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;`;
</div>
<div className="gn-v2-designer-meta">
<span><TableOutlined /> {designerDbTitle}</span>
{supportsTableDesignerSchemaSelection && designerSchemaTitle && <span><FolderOpenOutlined /> {designerSchemaTitle}</span>}
<span>{designerColumnSummary}</span>
{readOnly && <span>{t('table_designer.status.read_only', undefined, i18nLanguage)}</span>}
</div>
@@ -3651,13 +3823,39 @@ END;`;
alignItems: 'center'
}}
>
{supportsTableDesignerSchemaSelection && (
<Select
aria-label={t('data_sync.field.schema', undefined, i18nLanguage)}
value={designerSchemaTitle || undefined}
placeholder={t('data_sync.field.schema', undefined, i18nLanguage)}
loading={schemaLoading}
disabled={!schemaReady}
showSearch
optionFilterProp="label"
options={designerSchemaTitle && !schemaOptions.some(option => option.value === designerSchemaTitle)
? [{ label: designerSchemaTitle, value: designerSchemaTitle }, ...schemaOptions]
: schemaOptions}
onChange={handleSchemaChange}
style={{ minWidth: 150 }}
popupMatchSelectWidth={false}
/>
)}
{isNewTable && (
<>
<Input
{...noAutoCapInputProps}
placeholder={t('table_designer.placeholder.table_name', undefined, i18nLanguage)}
value={newTableName}
onChange={e => 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 && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>{t('table_designer.action.save', undefined, i18nLanguage)}</Button>}
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" disabled={supportsTableDesignerSchemaSelection && !schemaReady} onClick={generateDDL}>{t('table_designer.action.save', undefined, i18nLanguage)}</Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} loading={metadataLoading} onClick={handleRefreshDesigner}>{t('table_designer.action.refresh', undefined, i18nLanguage)}</Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>{t('table_designer.action.table_comment', undefined, i18nLanguage)}</Button>

View File

@@ -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',
});
});
});

View File

@@ -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<string, unknown>;
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,
});
};

View File

@@ -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: {

View File

@@ -1821,6 +1821,7 @@ interface AppState {
tableExportHistories: Record<string, TableExportHistoryEntry[]>;
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, "name" | "frequency">;
tableDesignerSchemaByConnection: Record<string, string>;
tableColumnOrders: Record<string, string[]>;
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<string, string> => {
const raw =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const result: Record<string, string> = {};
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<string, string[]> => {
@@ -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<AppState>()(
tableExportHistories: {},
tableAccessCount: {},
tableSortPreference: {},
tableDesignerSchemaByConnection: {},
tableColumnOrders: {},
enableColumnOrderMemory: true,
tablePinnedLeftColumns: {},
@@ -3707,6 +3732,8 @@ export const useStore = create<AppState>()(
),
nextConnections,
);
const nextDesignerSchemas = { ...state.tableDesignerSchemaByConnection };
delete nextDesignerSchemas[id];
return {
connections: nextConnections,
connectionTags: normalized.connectionTags,
@@ -3721,6 +3748,7 @@ export const useStore = create<AppState>()(
id,
nextConnections.map((connection) => connection.id),
),
tableDesignerSchemaByConnection: nextDesignerSchemas,
sidebarRootOrder: normalized.sidebarRootOrder,
};
}),
@@ -3732,10 +3760,16 @@ export const useStore = create<AppState>()(
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<AppState>()(
};
}),
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<AppState>()(
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<AppState>()(
tableSortPreference: sanitizeTableSortPreference(
state.tableSortPreference,
),
tableDesignerSchemaByConnection: sanitizeTableDesignerSchemaByConnection(
state.tableDesignerSchemaByConnection,
),
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
tablePinnedLeftColumns: sanitizeTableColumnOrders(