mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
## 关联 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:
@@ -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,
|
||||
|
||||
@@ -1337,6 +1337,7 @@ interface DataGridProps {
|
||||
resultSql?: string;
|
||||
resultExportAllSql?: string;
|
||||
dbName?: string;
|
||||
schemaName?: string;
|
||||
/** DDL 查询使用的数据库/命名空间;查询结果页不复用列元数据目标。 */
|
||||
ddlDbName?: string;
|
||||
/** DDL 查询使用的表名;查询结果页仅在该目标明确时显示 DDL 入口。 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
147
frontend/src/components/tableDesignerSchemaContext.test.ts
Normal file
147
frontend/src/components/tableDesignerSchemaContext.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
161
frontend/src/components/tableDesignerSchemaContext.ts
Normal file
161
frontend/src/components/tableDesignerSchemaContext.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user