mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(external-sql): 支持 SQL 文件指定执行数据库
- 为外部 SQL 文件增加连接与数据库单独绑定,并持久化保存 - 统一侧边栏、最近文件、大文件执行、工具栏和 AI 文件检查上下文 - 在文件与目录重命名、删除时迁移或清理绑定 - 按物理路径去重最近 SQL 文件并补充多语言与回归测试
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
} from './sidebar/useSidebarTreeLoaders';
|
||||
export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders';
|
||||
import {
|
||||
ExternalSQLBindingModal,
|
||||
ExternalSQLFileModal,
|
||||
useSidebarExternalSqlWorkflow,
|
||||
} from './sidebar/SidebarExternalSqlWorkflow';
|
||||
@@ -1462,6 +1463,7 @@ const Sidebar: React.FC<{
|
||||
handleRunSQLFile,
|
||||
handleOpenSQLFileFromToolbar,
|
||||
openExternalSQLFile,
|
||||
openExternalSQLBindingModal,
|
||||
openCreateExternalSQLFileModal,
|
||||
openRenameExternalSQLFileModal,
|
||||
openCreateExternalSQLDirectoryModal,
|
||||
@@ -1472,6 +1474,7 @@ const Sidebar: React.FC<{
|
||||
handleRemoveExternalSQLDirectory,
|
||||
handleRefreshExternalSQLDirectory,
|
||||
externalSQLFileModalProps,
|
||||
externalSQLBindingModalProps,
|
||||
} = useSidebarExternalSqlWorkflow({
|
||||
connections,
|
||||
externalSQLDirectories,
|
||||
@@ -3087,6 +3090,7 @@ const Sidebar: React.FC<{
|
||||
handleDeleteExternalSQLDirectory,
|
||||
handleRemoveExternalSQLDirectory,
|
||||
openExternalSQLFile,
|
||||
openExternalSQLBindingModal,
|
||||
openRenameExternalSQLFileModal,
|
||||
handleDeleteExternalSQLFile,
|
||||
extractObjectName,
|
||||
@@ -4267,6 +4271,7 @@ const Sidebar: React.FC<{
|
||||
</Modal>
|
||||
|
||||
<ExternalSQLFileModal {...externalSQLFileModalProps} />
|
||||
<ExternalSQLBindingModal {...externalSQLBindingModalProps} />
|
||||
|
||||
<FindInDatabaseModal
|
||||
open={findInDbContext.open}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPinnedTableShortcuts, buildRecentConnectionShortcuts } from './TabManager';
|
||||
import type { SavedConnection } from '../types';
|
||||
import {
|
||||
buildPinnedTableShortcuts,
|
||||
buildRecentConnectionShortcuts,
|
||||
buildRecentSQLFileShortcuts,
|
||||
} from './TabManager';
|
||||
import type { ExternalSQLDirectory, SavedConnection } from '../types';
|
||||
|
||||
const connection = (id: string, type: string): SavedConnection => ({
|
||||
id,
|
||||
@@ -53,4 +57,146 @@ describe('recent workbench shortcuts', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the latest persisted file binding for recent SQL file shortcuts', () => {
|
||||
const connections = [
|
||||
connection('mysql-1', 'mysql'),
|
||||
connection('mysql-2', 'mysql'),
|
||||
];
|
||||
const directories: ExternalSQLDirectory[] = [{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'legacy',
|
||||
fileBindings: [{
|
||||
filePath: 'D:/sql/scripts/report.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
}],
|
||||
createdAt: 1,
|
||||
}];
|
||||
|
||||
expect(buildRecentSQLFileShortcuts(connections, directories, [{
|
||||
filePath: 'D:/sql/scripts/report.sql',
|
||||
fileName: 'report.sql',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'legacy',
|
||||
openedAt: 1,
|
||||
}])).toEqual([
|
||||
expect.objectContaining({
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a bound SQL file only once after its execution database changes', () => {
|
||||
const connections = [
|
||||
connection('mysql-1', 'mysql'),
|
||||
connection('mysql-2', 'mysql'),
|
||||
];
|
||||
const directories: ExternalSQLDirectory[] = [{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'crawler',
|
||||
fileBindings: [{
|
||||
filePath: 'D:/sql/scripts/hancheng.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: '12315_dev',
|
||||
}],
|
||||
createdAt: 1,
|
||||
}];
|
||||
|
||||
expect(buildRecentSQLFileShortcuts(connections, directories, [
|
||||
{
|
||||
filePath: 'D:/sql/scripts/hancheng.sql',
|
||||
fileName: 'hancheng.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: '12315_dev',
|
||||
openedAt: 2,
|
||||
},
|
||||
{
|
||||
filePath: 'D:\\sql\\scripts\\hancheng.sql',
|
||||
fileName: 'hancheng.sql',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'crawler',
|
||||
openedAt: 1,
|
||||
},
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
filePath: 'D:/sql/scripts/hancheng.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: '12315_dev',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the recent context when the same directory path has multiple database bindings', () => {
|
||||
const connections = [
|
||||
connection('mysql-1', 'mysql'),
|
||||
connection('mysql-2', 'mysql'),
|
||||
];
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/shared',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'orders',
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'dir-2',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/shared',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildRecentSQLFileShortcuts(connections, directories, [{
|
||||
filePath: 'D:/sql/shared/report.sql',
|
||||
fileName: 'report.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
openedAt: 1,
|
||||
}])).toEqual([
|
||||
expect.objectContaining({
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores the original recent-file context after a file-specific binding is cleared', () => {
|
||||
const connections = [
|
||||
connection('mysql-1', 'mysql'),
|
||||
connection('mysql-2', 'mysql'),
|
||||
];
|
||||
const directories: ExternalSQLDirectory[] = [{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/shared',
|
||||
connectionId: 'mysql-1',
|
||||
dbName: 'orders',
|
||||
createdAt: 1,
|
||||
}];
|
||||
|
||||
expect(buildRecentSQLFileShortcuts(connections, directories, [{
|
||||
filePath: 'D:/sql/shared/report.sql',
|
||||
fileName: 'report.sql',
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
openedAt: 1,
|
||||
}])).toEqual([
|
||||
expect.objectContaining({
|
||||
connectionId: 'mysql-2',
|
||||
dbName: 'reporting',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
normalizeSQLFileReadContent,
|
||||
} from '../utils/sqlFileTabDirty';
|
||||
import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
|
||||
import { buildExternalSQLTabId } from '../utils/externalSqlTree';
|
||||
import {
|
||||
buildExternalSQLTabId,
|
||||
normalizeExternalSQLPath,
|
||||
resolveExternalSQLFileBinding,
|
||||
} from '../utils/externalSqlTree';
|
||||
import { buildSQLFileExecutionWorkbenchTab } from '../utils/sqlFileExecutionTab';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, resolveDockedActiveTabId } from '../utils/closeTabShortcut';
|
||||
@@ -203,6 +207,37 @@ export const buildPinnedTableShortcuts = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const buildRecentSQLFileShortcuts = (
|
||||
connections: SavedConnection[],
|
||||
directories: ExternalSQLDirectory[],
|
||||
recentFiles: RecentSQLFile[],
|
||||
): RecentSQLFile[] => {
|
||||
const connectionIds = new Set(connections.map((connection) => connection.id));
|
||||
const seenFilePaths = new Set<string>();
|
||||
return [...recentFiles]
|
||||
.map((file) => {
|
||||
const binding = resolveExternalSQLFileBinding(directories, file.filePath, {
|
||||
connectionId: file.connectionId,
|
||||
dbName: file.dbName,
|
||||
});
|
||||
return binding
|
||||
? { ...file, connectionId: binding.connectionId, dbName: binding.dbName }
|
||||
: file;
|
||||
})
|
||||
.filter((file) => connectionIds.has(file.connectionId))
|
||||
.sort((left, right) => right.openedAt - left.openedAt)
|
||||
.filter((file) => {
|
||||
const normalizedPath = normalizeExternalSQLPath(file.filePath);
|
||||
const filePathKey = /^[a-z]:\//iu.test(normalizedPath) || normalizedPath.startsWith('//')
|
||||
? normalizedPath.toLowerCase()
|
||||
: normalizedPath;
|
||||
if (!filePathKey || seenFilePaths.has(filePathKey)) return false;
|
||||
seenFilePaths.add(filePathKey);
|
||||
return true;
|
||||
})
|
||||
.slice(0, RECENT_WORKBENCH_ITEM_LIMIT);
|
||||
};
|
||||
|
||||
const buildLinkedExternalSQLDirectoryShortcuts = (
|
||||
connections: SavedConnection[],
|
||||
directories: ExternalSQLDirectory[],
|
||||
@@ -1368,11 +1403,8 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
|
||||
[connectionById, savedQueries],
|
||||
);
|
||||
const recentSQLFileShortcuts = useMemo(
|
||||
() => [...recentSQLFiles]
|
||||
.filter((file) => connectionById.has(file.connectionId))
|
||||
.sort((left, right) => right.openedAt - left.openedAt)
|
||||
.slice(0, RECENT_WORKBENCH_ITEM_LIMIT),
|
||||
[connectionById, recentSQLFiles],
|
||||
() => buildRecentSQLFileShortcuts(queryCapableConnections, externalSQLDirectories, recentSQLFiles),
|
||||
[externalSQLDirectories, queryCapableConnections, recentSQLFiles],
|
||||
);
|
||||
const pinnedTableShortcuts = useMemo(
|
||||
() => buildPinnedTableShortcuts(queryCapableConnections, pinnedSidebarTables),
|
||||
@@ -1450,9 +1482,13 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
|
||||
}, [addTab, connectionById]);
|
||||
|
||||
const handleOpenRecentSQLFile = useCallback(async (file: RecentSQLFile) => {
|
||||
const connectionId = String(file.connectionId || '').trim();
|
||||
const dbName = String(file.dbName || '').trim();
|
||||
const filePath = String(file.filePath || '').trim();
|
||||
const fileBinding = resolveExternalSQLFileBinding(externalSQLDirectories, filePath, {
|
||||
connectionId: file.connectionId,
|
||||
dbName: file.dbName,
|
||||
});
|
||||
const connectionId = String(fileBinding?.connectionId || file.connectionId || '').trim();
|
||||
const dbName = String(fileBinding?.dbName || file.dbName || '').trim();
|
||||
if (!connectionId || !connectionById.has(connectionId)) {
|
||||
message.error(t('sidebar.message.connection_config_not_found'));
|
||||
return;
|
||||
@@ -1500,7 +1536,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
|
||||
} finally {
|
||||
setOpeningRecentSQLFileKey((current) => current === openKey ? null : current);
|
||||
}
|
||||
}, [addTab, connectionById]);
|
||||
}, [addTab, connectionById, externalSQLDirectories]);
|
||||
|
||||
const EmptyWorkbench = (
|
||||
<div className="gn-v2-empty-workbench">
|
||||
|
||||
@@ -14,6 +14,16 @@ const connections: SavedConnection[] = [
|
||||
user: 'root',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
name: '报表库',
|
||||
config: {
|
||||
type: 'sqlserver',
|
||||
host: '192.168.1.10',
|
||||
port: 1433,
|
||||
user: 'reporter',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('aiExternalSqlFileInsights', () => {
|
||||
@@ -25,6 +35,11 @@ describe('aiExternalSqlFileInsights', () => {
|
||||
path: 'D:/sql/reports',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
fileBindings: [{
|
||||
filePath: 'D:/sql/reports/daily.sql',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
}],
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
@@ -56,9 +71,10 @@ describe('aiExternalSqlFileInsights', () => {
|
||||
expect(snapshot.hasMatchedDirectory).toBe(true);
|
||||
expect(snapshot.directory).toMatchObject({
|
||||
name: '报表脚本',
|
||||
connectionName: '本地开发库',
|
||||
connectionType: 'mysql',
|
||||
dbName: 'crm',
|
||||
connectionName: '报表库',
|
||||
connectionType: 'sqlserver',
|
||||
dbName: 'reporting',
|
||||
bindingSource: 'file',
|
||||
});
|
||||
expect(snapshot.hasOpenTab).toBe(true);
|
||||
expect(snapshot.openTabCount).toBe(1);
|
||||
@@ -66,4 +82,32 @@ describe('aiExternalSqlFileInsights', () => {
|
||||
expect(snapshot.contentPreview).toBe('SELECT * FRO');
|
||||
expect(snapshot.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the original directory metadata after a file-specific binding is cleared', () => {
|
||||
const snapshot = buildExternalSQLFileSnapshot({
|
||||
filePath: 'D:/sql/reports/daily.sql',
|
||||
readResult: {
|
||||
content: 'SELECT 1;',
|
||||
filePath: 'D:/sql/reports/daily.sql',
|
||||
name: 'daily.sql',
|
||||
},
|
||||
externalSQLDirectories: [{
|
||||
id: 'dir-1',
|
||||
name: '报表脚本',
|
||||
path: 'D:/sql/reports',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
createdAt: 1,
|
||||
}],
|
||||
connections,
|
||||
});
|
||||
|
||||
expect(snapshot.directory).toMatchObject({
|
||||
connectionId: 'conn-1',
|
||||
connectionName: '本地开发库',
|
||||
connectionType: 'mysql',
|
||||
dbName: 'crm',
|
||||
});
|
||||
expect(snapshot.directory).not.toHaveProperty('bindingSource');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
|
||||
import { resolveExternalSQLFileBinding } from '../../utils/externalSqlTree';
|
||||
import {
|
||||
findBestMatchingExternalSQLDirectory,
|
||||
normalizeExternalSQLPath,
|
||||
@@ -73,7 +74,15 @@ export const buildExternalSQLFileSnapshot = (params: {
|
||||
const payload = normalizeFileReadPayload(readResult);
|
||||
const resolvedFilePath = normalizeExternalSQLPath(payload.filePath || requestedFilePath);
|
||||
const matchedDirectory = findBestMatchingExternalSQLDirectory(resolvedFilePath, externalSQLDirectories);
|
||||
const matchedConnection = connections.find((item) => item.id === matchedDirectory?.connectionId);
|
||||
const fileBinding = resolveExternalSQLFileBinding(
|
||||
externalSQLDirectories,
|
||||
resolvedFilePath,
|
||||
matchedDirectory
|
||||
? { connectionId: matchedDirectory.connectionId, dbName: matchedDirectory.dbName }
|
||||
: undefined,
|
||||
);
|
||||
const effectiveConnectionId = fileBinding?.connectionId || matchedDirectory?.connectionId || '';
|
||||
const matchedConnection = connections.find((item) => item.id === effectiveConnectionId);
|
||||
const matchingTabs = tabs.filter(
|
||||
(tab) => normalizeExternalSQLPath(tab.filePath || '').toLowerCase() === resolvedFilePath.toLowerCase(),
|
||||
);
|
||||
@@ -91,10 +100,11 @@ export const buildExternalSQLFileSnapshot = (params: {
|
||||
id: matchedDirectory.id,
|
||||
name: matchedDirectory.name,
|
||||
path: matchedDirectory.path,
|
||||
connectionId: matchedDirectory.connectionId || '',
|
||||
connectionId: effectiveConnectionId,
|
||||
connectionName: matchedConnection?.name || '',
|
||||
connectionType: matchedConnection?.config?.type || '',
|
||||
dbName: matchedDirectory.dbName || '',
|
||||
dbName: fileBinding?.dbName || matchedDirectory.dbName || '',
|
||||
...(fileBinding ? { bindingSource: 'file' } : {}),
|
||||
} : null,
|
||||
hasOpenTab: matchingTabs.length > 0,
|
||||
openTabCount: matchingTabs.length,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Button, Form, Input, Progress, message } from 'antd';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, Progress, Select, message } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import Modal from '../common/ResizableDraggableModal';
|
||||
import type { SavedConnection, ExternalSQLDirectory } from '../../types';
|
||||
@@ -7,9 +7,17 @@ import { noAutoCapInputProps } from '../../utils/inputAutoCap';
|
||||
import {
|
||||
buildExternalSQLDirectoryId,
|
||||
buildExternalSQLTabId,
|
||||
moveExternalSQLFileBindings,
|
||||
normalizeExternalSQLPath,
|
||||
removeExternalSQLFileBindings,
|
||||
resolveExternalSQLFileBinding,
|
||||
setExternalSQLFileBinding,
|
||||
} from '../../utils/externalSqlTree';
|
||||
import { buildSQLFileExecutionWorkbenchTab } from '../../utils/sqlFileExecutionTab';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { filterVisibleDatabaseNames } from '../../utils/databaseVisibility';
|
||||
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
|
||||
import { resolveConnectionHostSummary } from '../../utils/tabDisplay';
|
||||
import { t } from '../../i18n';
|
||||
import { resolveSidebarNodeConnectionId } from '../sidebarV2Utils';
|
||||
import {
|
||||
@@ -20,6 +28,7 @@ import {
|
||||
OpenSQLFile,
|
||||
SelectSQLDirectory,
|
||||
ReadSQLFile,
|
||||
DBGetDatabases,
|
||||
CreateSQLFile,
|
||||
CreateSQLDirectory,
|
||||
DeleteSQLFile,
|
||||
@@ -94,6 +103,22 @@ type SQLFileExecutionModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ExternalSQLBindingModalProps = {
|
||||
open: boolean;
|
||||
form: FormInstance;
|
||||
connections: SavedConnection[];
|
||||
filePath: string;
|
||||
databaseOptions: string[];
|
||||
loadingDatabases: boolean;
|
||||
databaseLoadError: string;
|
||||
hasExplicitBinding: boolean;
|
||||
saving: boolean;
|
||||
onConnectionChange: (connectionId: string) => void;
|
||||
onClearBinding: () => void;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const normalizeExternalSQLFileName = (rawName: unknown): string => {
|
||||
const name = String(rawName || '').trim();
|
||||
if (!name) return '';
|
||||
@@ -265,6 +290,90 @@ export const ExternalSQLFileModal: React.FC<ExternalSQLFileModalProps> = ({
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export const ExternalSQLBindingModal: React.FC<ExternalSQLBindingModalProps> = ({
|
||||
open,
|
||||
form,
|
||||
connections,
|
||||
filePath,
|
||||
databaseOptions,
|
||||
loadingDatabases,
|
||||
databaseLoadError,
|
||||
hasExplicitBinding,
|
||||
saving,
|
||||
onConnectionChange,
|
||||
onClearBinding,
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
const connectionOptions = connections
|
||||
.filter((connection) => getDataSourceCapabilities(connection.config).supportsQueryEditor)
|
||||
.map((connection) => {
|
||||
const host = resolveConnectionHostSummary(connection.config);
|
||||
return {
|
||||
value: connection.id,
|
||||
label: host ? `${connection.name || connection.id} (${host})` : connection.name || connection.id,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('sidebar.external_sql_binding.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={saving}
|
||||
maskClosable={!saving}
|
||||
closable={!saving}
|
||||
>
|
||||
<div
|
||||
title={filePath}
|
||||
style={{ marginBottom: 16, color: 'var(--gn-text-secondary)', wordBreak: 'break-all' }}
|
||||
>
|
||||
{filePath}
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="connectionId"
|
||||
label={t('data_export.label.connection')}
|
||||
rules={[{ required: true, message: t('sidebar.message.select_connection_or_database_first') }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={connectionOptions}
|
||||
placeholder={t('data_export.workbench.placeholder.select_connection')}
|
||||
onChange={onConnectionChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="dbName"
|
||||
label={t('data_export.label.database')}
|
||||
rules={[{ required: true, message: t('sidebar.message.select_connection_or_database_first') }]}
|
||||
extra={databaseLoadError || undefined}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={loadingDatabases}
|
||||
disabled={!form.getFieldValue('connectionId') || loadingDatabases}
|
||||
options={databaseOptions.map((database) => ({ value: database, label: database }))}
|
||||
placeholder={loadingDatabases
|
||||
? t('data_export.workbench.placeholder.loading_databases')
|
||||
: t('data_export.workbench.placeholder.select_database')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{hasExplicitBinding && (
|
||||
<Button type="link" style={{ paddingInline: 0 }} disabled={saving} onClick={onClearBinding}>
|
||||
{t('sidebar.external_sql_binding.clear_override')}
|
||||
</Button>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const SQLFileExecutionModal: React.FC<SQLFileExecutionModalProps> = ({
|
||||
title,
|
||||
state,
|
||||
@@ -324,6 +433,58 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
const [externalSQLFileForm] = Form.useForm();
|
||||
const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState<ExternalSQLFileModalMode>('create');
|
||||
const [externalSQLFileTarget, setExternalSQLFileTarget] = useState<any>(null);
|
||||
const [isExternalSQLBindingModalOpen, setIsExternalSQLBindingModalOpen] = useState(false);
|
||||
const [externalSQLBindingForm] = Form.useForm();
|
||||
const [externalSQLBindingTarget, setExternalSQLBindingTarget] = useState<any>(null);
|
||||
const [externalSQLBindingDatabases, setExternalSQLBindingDatabases] = useState<string[]>([]);
|
||||
const [externalSQLBindingDatabaseError, setExternalSQLBindingDatabaseError] = useState('');
|
||||
const [loadingExternalSQLBindingDatabases, setLoadingExternalSQLBindingDatabases] = useState(false);
|
||||
const [savingExternalSQLBinding, setSavingExternalSQLBinding] = useState(false);
|
||||
const externalSQLBindingDatabaseRequestRef = useRef(0);
|
||||
|
||||
const loadExternalSQLBindingDatabases = useCallback(async (
|
||||
connectionId: string,
|
||||
preferredDbName = '',
|
||||
) => {
|
||||
const requestId = ++externalSQLBindingDatabaseRequestRef.current;
|
||||
const connection = connections.find((item) => item.id === String(connectionId || '').trim());
|
||||
setExternalSQLBindingDatabaseError('');
|
||||
if (!connection) {
|
||||
setExternalSQLBindingDatabases([]);
|
||||
setLoadingExternalSQLBindingDatabases(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingExternalSQLBindingDatabases(true);
|
||||
const fallbackNames = [preferredDbName, String(connection.config.database || '').trim()].filter(Boolean);
|
||||
try {
|
||||
const result = await DBGetDatabases(buildRpcConnectionConfig(connection.config) as any);
|
||||
if (requestId !== externalSQLBindingDatabaseRequestRef.current) return;
|
||||
if (!result.success) {
|
||||
setExternalSQLBindingDatabases(Array.from(new Set(fallbackNames)));
|
||||
setExternalSQLBindingDatabaseError(result.message || t('data_export.message.load_databases_failed'));
|
||||
return;
|
||||
}
|
||||
const names = (Array.isArray(result.data) ? result.data : [])
|
||||
.map((row: any) => String(row?.Database || row?.database || Object.values(row || {})[0] || '').trim())
|
||||
.filter(Boolean);
|
||||
const visibleNames = filterVisibleDatabaseNames(connection, names);
|
||||
setExternalSQLBindingDatabases(
|
||||
Array.from(new Set([...fallbackNames, ...visibleNames]))
|
||||
.sort((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== externalSQLBindingDatabaseRequestRef.current) return;
|
||||
setExternalSQLBindingDatabases(Array.from(new Set(fallbackNames)));
|
||||
setExternalSQLBindingDatabaseError(
|
||||
error instanceof Error ? error.message : t('data_export.message.load_databases_failed'),
|
||||
);
|
||||
} finally {
|
||||
if (requestId === externalSQLBindingDatabaseRequestRef.current) {
|
||||
setLoadingExternalSQLBindingDatabases(false);
|
||||
}
|
||||
}
|
||||
}, [connections]);
|
||||
|
||||
const selectSQLFileForExecution = useCallback(async () => {
|
||||
const backendApp = typeof window !== 'undefined' ? (window as any).go?.app?.App : undefined;
|
||||
@@ -409,9 +570,19 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
message.error(t('sidebar.message.sql_file_path_incomplete'));
|
||||
return;
|
||||
}
|
||||
const fileBinding = resolveExternalSQLFileBinding(
|
||||
externalSQLDirectories,
|
||||
data.filePath,
|
||||
{
|
||||
connectionId: String(ctx?.connectionId || '').trim(),
|
||||
dbName: String(ctx?.dbName || '').trim(),
|
||||
},
|
||||
);
|
||||
const connectionId = fileBinding?.connectionId || ctx.connectionId;
|
||||
const dbName = fileBinding?.dbName || String(ctx.dbName || '').trim();
|
||||
openSQLFileExecutionWorkbench({
|
||||
connectionId: ctx.connectionId,
|
||||
dbName: ctx.dbName || '',
|
||||
connectionId,
|
||||
dbName,
|
||||
filePath: data.filePath,
|
||||
fileName: data.fileName,
|
||||
fileSizeMB: data.fileSizeMB,
|
||||
@@ -441,6 +612,125 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
};
|
||||
};
|
||||
|
||||
const closeExternalSQLBindingModal = () => {
|
||||
externalSQLBindingDatabaseRequestRef.current += 1;
|
||||
setIsExternalSQLBindingModalOpen(false);
|
||||
setExternalSQLBindingTarget(null);
|
||||
setExternalSQLBindingDatabases([]);
|
||||
setExternalSQLBindingDatabaseError('');
|
||||
setLoadingExternalSQLBindingDatabases(false);
|
||||
externalSQLBindingForm.resetFields();
|
||||
};
|
||||
|
||||
const openExternalSQLBindingModal = (fileNode: any) => {
|
||||
const filePath = String(fileNode?.dataRef?.path || '').trim();
|
||||
const directoryId = String(fileNode?.dataRef?.directoryId || '').trim();
|
||||
if (!filePath || !directoryId) {
|
||||
message.error(t('sidebar.message.sql_file_path_incomplete'));
|
||||
return;
|
||||
}
|
||||
const fallbackContext = resolveExternalSQLExecutionContext();
|
||||
const fileConnectionId = String(fileNode?.dataRef?.connectionId || '').trim();
|
||||
const fallbackConnectionId = String(fallbackContext.connectionId || '').trim();
|
||||
const connectionId = [fileConnectionId, fallbackConnectionId]
|
||||
.find((candidate) => connections.some((connection) => connection.id === candidate)) || '';
|
||||
const dbName = connectionId === fileConnectionId
|
||||
? String(fileNode?.dataRef?.dbName || '').trim()
|
||||
: String(fallbackContext.dbName || '').trim();
|
||||
setExternalSQLBindingTarget(fileNode);
|
||||
externalSQLBindingForm.setFieldsValue({
|
||||
connectionId: connectionId || undefined,
|
||||
dbName: dbName || undefined,
|
||||
});
|
||||
setIsExternalSQLBindingModalOpen(true);
|
||||
if (connectionId) {
|
||||
void loadExternalSQLBindingDatabases(connectionId, dbName);
|
||||
} else {
|
||||
setExternalSQLBindingDatabases([]);
|
||||
setExternalSQLBindingDatabaseError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExternalSQLBindingConnectionChange = (connectionId: string) => {
|
||||
externalSQLBindingForm.setFieldsValue({ dbName: undefined });
|
||||
void loadExternalSQLBindingDatabases(connectionId);
|
||||
};
|
||||
|
||||
const saveExternalSQLBindingTarget = async (
|
||||
target: { connectionId: string; dbName: string } | null,
|
||||
) => {
|
||||
const filePath = String(externalSQLBindingTarget?.dataRef?.path || '').trim();
|
||||
const directoryId = String(externalSQLBindingTarget?.dataRef?.directoryId || '').trim();
|
||||
const directory = externalSQLDirectories.find((item) => item.id === directoryId);
|
||||
if (!filePath || !directory) {
|
||||
message.error(t('sidebar.message.external_sql_directory_not_found'));
|
||||
return false;
|
||||
}
|
||||
if (target) {
|
||||
const connection = connections.find((item) => item.id === target.connectionId);
|
||||
if (!connection || !getDataSourceCapabilities(connection.config).supportsQueryEditor) {
|
||||
message.error(t('sidebar.message.connection_config_not_found'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const nextDirectory = setExternalSQLFileBinding(directory, filePath, target);
|
||||
saveExternalSQLDirectory(nextDirectory);
|
||||
const nextDirectories = externalSQLDirectories.map((item) => (
|
||||
item.id === directoryId ? nextDirectory : item
|
||||
));
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExternalSQLBindingOk = async () => {
|
||||
try {
|
||||
const values = await externalSQLBindingForm.validateFields();
|
||||
setSavingExternalSQLBinding(true);
|
||||
const saved = await saveExternalSQLBindingTarget({
|
||||
connectionId: String(values.connectionId || '').trim(),
|
||||
dbName: String(values.dbName || '').trim(),
|
||||
});
|
||||
if (!saved) return;
|
||||
message.success(t('sidebar.message.external_sql_file_binding_saved'));
|
||||
closeExternalSQLBindingModal();
|
||||
} catch (error) {
|
||||
if (!(error && typeof error === 'object' && 'errorFields' in error)) {
|
||||
message.error(error instanceof Error ? error.message : t('common.unknown'));
|
||||
}
|
||||
} finally {
|
||||
setSavingExternalSQLBinding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearExternalSQLBinding = async () => {
|
||||
try {
|
||||
setSavingExternalSQLBinding(true);
|
||||
const saved = await saveExternalSQLBindingTarget(null);
|
||||
if (!saved) return;
|
||||
message.success(t('sidebar.message.external_sql_file_binding_cleared'));
|
||||
closeExternalSQLBindingModal();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t('common.unknown'));
|
||||
} finally {
|
||||
setSavingExternalSQLBinding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transformExternalSQLDirectoryBindings = (
|
||||
transform: (directory: ExternalSQLDirectory) => ExternalSQLDirectory,
|
||||
): ExternalSQLDirectory[] | undefined => {
|
||||
let changed = false;
|
||||
const nextDirectories = externalSQLDirectories.map((directory) => {
|
||||
const nextDirectory = transform(directory);
|
||||
if (nextDirectory !== directory) {
|
||||
changed = true;
|
||||
saveExternalSQLDirectory(nextDirectory);
|
||||
}
|
||||
return nextDirectory;
|
||||
});
|
||||
return changed ? nextDirectories : undefined;
|
||||
};
|
||||
|
||||
const openExternalSQLFile = async (fileNode: any) => {
|
||||
const fileContext = {
|
||||
connectionId: String(fileNode?.dataRef?.connectionId || '').trim(),
|
||||
@@ -455,7 +745,6 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
message.error(t('sidebar.message.sql_file_path_incomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await ReadSQLFile(filePath);
|
||||
if (!res.success) {
|
||||
if (res.message !== '已取消') {
|
||||
@@ -583,10 +872,14 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
}
|
||||
const payload = (res.data && typeof res.data === 'object') ? res.data as Record<string, unknown> : {};
|
||||
const nextFilePath = String(payload.filePath || '').trim();
|
||||
let nextDirectories: ExternalSQLDirectory[] | undefined;
|
||||
if (nextFilePath) {
|
||||
updateRecentSQLFilePath(filePath, nextFilePath);
|
||||
nextDirectories = transformExternalSQLDirectoryBindings(
|
||||
(directory) => moveExternalSQLFileBindings(directory, filePath, nextFilePath),
|
||||
);
|
||||
}
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
message.success(t('sidebar.message.sql_file_renamed'));
|
||||
} else if (externalSQLFileModalMode === 'create-directory') {
|
||||
const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget);
|
||||
@@ -636,8 +929,9 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
matchingDirectories.forEach((directory) => {
|
||||
const connectionId = String(directory.connectionId || '').trim();
|
||||
const dbName = String(directory.dbName || '').trim();
|
||||
const movedDirectory = moveExternalSQLFileBindings(directory, directoryPath, nextPath);
|
||||
const nextDirectory: ExternalSQLDirectory = {
|
||||
...directory,
|
||||
...movedDirectory,
|
||||
id: buildExternalSQLDirectoryId(connectionId, dbName, nextPath),
|
||||
name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'),
|
||||
path: nextPath,
|
||||
@@ -656,7 +950,12 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
nextDirectoriesById.forEach((directory) => saveExternalSQLDirectory(directory));
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
} else {
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
const nextDirectories = nextPath
|
||||
? transformExternalSQLDirectoryBindings(
|
||||
(directory) => moveExternalSQLFileBindings(directory, directoryPath, nextPath),
|
||||
)
|
||||
: undefined;
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
}
|
||||
message.success(t('sidebar.message.sql_directory_renamed'));
|
||||
}
|
||||
@@ -688,7 +987,10 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
return;
|
||||
}
|
||||
removeRecentSQLFilesByPath(filePath);
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
const nextDirectories = transformExternalSQLDirectoryBindings(
|
||||
(directory) => removeExternalSQLFileBindings(directory, filePath),
|
||||
);
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
message.success(t('sidebar.message.sql_file_deleted'));
|
||||
},
|
||||
});
|
||||
@@ -731,7 +1033,10 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
}
|
||||
} else {
|
||||
await refreshGlobalExternalSQLRootNode(false);
|
||||
const nextDirectories = transformExternalSQLDirectoryBindings(
|
||||
(directory) => removeExternalSQLFileBindings(directory, directoryPath),
|
||||
);
|
||||
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
|
||||
}
|
||||
message.success(t('sidebar.message.sql_directory_deleted'));
|
||||
},
|
||||
@@ -803,6 +1108,7 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
handleRunSQLFile,
|
||||
handleOpenSQLFileFromToolbar,
|
||||
openExternalSQLFile,
|
||||
openExternalSQLBindingModal,
|
||||
openCreateExternalSQLFileModal,
|
||||
openRenameExternalSQLFileModal,
|
||||
openCreateExternalSQLDirectoryModal,
|
||||
@@ -820,5 +1126,22 @@ export const useSidebarExternalSqlWorkflow = ({
|
||||
onOk: handleExternalSQLFileModalOk,
|
||||
onCancel: closeExternalSQLFileModal,
|
||||
},
|
||||
externalSQLBindingModalProps: {
|
||||
open: isExternalSQLBindingModalOpen,
|
||||
form: externalSQLBindingForm,
|
||||
connections,
|
||||
filePath: String(externalSQLBindingTarget?.dataRef?.path || '').trim(),
|
||||
databaseOptions: externalSQLBindingDatabases,
|
||||
loadingDatabases: loadingExternalSQLBindingDatabases,
|
||||
databaseLoadError: externalSQLBindingDatabaseError,
|
||||
hasExplicitBinding: externalSQLBindingTarget?.dataRef?.hasExplicitBinding === true,
|
||||
saving: savingExternalSQLBinding,
|
||||
onConnectionChange: handleExternalSQLBindingConnectionChange,
|
||||
onClearBinding: () => { void handleClearExternalSQLBinding(); },
|
||||
onOk: () => { void handleExternalSQLBindingOk(); },
|
||||
onCancel: () => {
|
||||
if (!savingExternalSQLBinding) closeExternalSQLBindingModal();
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildSidebarLegacyNodeMenuItems } from './sidebarLegacyNodeMenu';
|
||||
|
||||
describe('external SQL file context menu', () => {
|
||||
it('opens the persisted database binding workflow from the shared legacy/v2 menu', () => {
|
||||
const openExternalSQLFile = vi.fn();
|
||||
const openExternalSQLBindingModal = vi.fn();
|
||||
const items = buildSidebarLegacyNodeMenuItems({
|
||||
type: 'external-sql-file',
|
||||
title: 'report.sql',
|
||||
dataRef: {
|
||||
path: 'D:/sql/report.sql',
|
||||
directoryId: 'dir-1',
|
||||
},
|
||||
}, {
|
||||
openExternalSQLFile,
|
||||
openExternalSQLBindingModal,
|
||||
openRenameExternalSQLFileModal: vi.fn(),
|
||||
openCreateExternalSQLFileModal: vi.fn(),
|
||||
openCreateExternalSQLDirectoryModal: vi.fn(),
|
||||
handleDeleteExternalSQLFile: vi.fn(),
|
||||
}) as any[];
|
||||
|
||||
expect(items.map((item) => item?.key)).toContain('bind-external-sql-file-database');
|
||||
items.find((item) => item?.key === 'bind-external-sql-file-database')?.onClick?.();
|
||||
expect(openExternalSQLBindingModal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'external-sql-file',
|
||||
}));
|
||||
expect(openExternalSQLFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -382,6 +382,7 @@ export const buildSidebarLegacyNodeMenuItems = (
|
||||
handleDeleteExternalSQLDirectory,
|
||||
handleRemoveExternalSQLDirectory,
|
||||
openExternalSQLFile,
|
||||
openExternalSQLBindingModal,
|
||||
openRenameExternalSQLFileModal,
|
||||
handleDeleteExternalSQLFile,
|
||||
extractObjectName,
|
||||
@@ -1855,6 +1856,14 @@ export const buildSidebarLegacyNodeMenuItems = (
|
||||
void openExternalSQLFile(node);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'bind-external-sql-file-database',
|
||||
label: t('sidebar.menu.bind_sql_file_database'),
|
||||
icon: <LinkOutlined />,
|
||||
onClick: () => {
|
||||
openExternalSQLBindingModal(node);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'rename-external-sql-file',
|
||||
label: t('sidebar.menu.rename_sql_file'),
|
||||
|
||||
@@ -1958,6 +1958,13 @@ describe('store appearance persistence', () => {
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
fileBindings: [
|
||||
{
|
||||
filePath: 'D:\\sql\\scripts\\report.sql',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
@@ -1967,6 +1974,13 @@ describe('store appearance persistence', () => {
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
fileBindings: [
|
||||
{
|
||||
filePath: 'D:/sql/scripts/report.sql',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
@@ -1996,6 +2010,13 @@ describe('store appearance persistence', () => {
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
fileBindings: [
|
||||
{
|
||||
filePath: 'D:/sql/scripts/report.sql',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
type ShortcutPlatformBinding,
|
||||
type ShortcutPlatform,
|
||||
} from "./utils/shortcuts";
|
||||
import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree";
|
||||
import {
|
||||
buildExternalSQLDirectoryId,
|
||||
normalizeExternalSQLPath,
|
||||
} from "./utils/externalSqlTree";
|
||||
import {
|
||||
DEFAULT_SQL_SNIPPETS,
|
||||
BUILTIN_SNIPPET_MAP,
|
||||
@@ -2171,6 +2174,23 @@ const resolveExternalSQLDirectoryName = (name: unknown, path: string): string =>
|
||||
return pathSegment || translate("sidebar.sql_directory.default_name");
|
||||
};
|
||||
|
||||
const sanitizeExternalSQLFileBindings = (
|
||||
value: unknown,
|
||||
): NonNullable<ExternalSQLDirectory["fileBindings"]> => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const bindings = new Map<string, NonNullable<ExternalSQLDirectory["fileBindings"]>[number]>();
|
||||
value.forEach((entry) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const filePath = normalizeExternalSQLPath(toTrimmedString(raw.filePath));
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
if (!filePath || !connectionId || !dbName) return;
|
||||
bindings.set(filePath, { filePath, connectionId, dbName });
|
||||
});
|
||||
return [...bindings.values()];
|
||||
};
|
||||
|
||||
const sanitizeExternalSQLDirectories = (
|
||||
value: unknown,
|
||||
): ExternalSQLDirectory[] => {
|
||||
@@ -2184,6 +2204,7 @@ const sanitizeExternalSQLDirectories = (
|
||||
if (!path) return;
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
const fileBindings = sanitizeExternalSQLFileBindings(raw.fileBindings);
|
||||
const id =
|
||||
toTrimmedString(
|
||||
raw.id,
|
||||
@@ -2197,6 +2218,7 @@ const sanitizeExternalSQLDirectories = (
|
||||
path,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(dbName ? { dbName } : {}),
|
||||
...(fileBindings.length > 0 ? { fileBindings } : {}),
|
||||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||||
? Number(raw.createdAt)
|
||||
: Date.now(),
|
||||
@@ -5005,6 +5027,7 @@ export const useStore = create<AppState>()(
|
||||
}
|
||||
const connectionId = toTrimmedString(directory.connectionId);
|
||||
const dbName = toTrimmedString(directory.dbName);
|
||||
const fileBindings = sanitizeExternalSQLFileBindings(directory.fileBindings);
|
||||
const nextDirectory: ExternalSQLDirectory = {
|
||||
id:
|
||||
toTrimmedString(
|
||||
@@ -5015,6 +5038,7 @@ export const useStore = create<AppState>()(
|
||||
path,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(dbName ? { dbName } : {}),
|
||||
...(fileBindings.length > 0 ? { fileBindings } : {}),
|
||||
createdAt: Number.isFinite(Number(directory.createdAt))
|
||||
? Number(directory.createdAt)
|
||||
: Date.now(),
|
||||
|
||||
@@ -623,9 +623,16 @@ export interface ExternalSQLDirectory {
|
||||
path: string;
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
fileBindings?: ExternalSQLFileBinding[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLFileBinding {
|
||||
filePath: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
}
|
||||
|
||||
export interface ExternalSQLTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
import { buildExternalSQLRootNode, buildExternalSQLTabId } from './externalSqlTree';
|
||||
import {
|
||||
buildExternalSQLRootNode,
|
||||
buildExternalSQLTabId,
|
||||
moveExternalSQLFileBindings,
|
||||
removeExternalSQLFileBindings,
|
||||
resolveExternalSQLFileBinding,
|
||||
setExternalSQLFileBinding,
|
||||
} from './externalSqlTree';
|
||||
|
||||
describe('externalSqlTree helpers', () => {
|
||||
it('builds external SQL root node with nested directory and file entries', () => {
|
||||
@@ -145,6 +152,53 @@ describe('externalSqlTree helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a file binding ahead of its directory binding without affecting sibling files', () => {
|
||||
const node = buildExternalSQLRootNode({
|
||||
directories: [
|
||||
{
|
||||
id: 'dir-bound',
|
||||
name: 'bound scripts',
|
||||
path: 'D:/sql/bound',
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
fileBindings: [
|
||||
{
|
||||
filePath: 'D:/sql/bound/report.sql',
|
||||
connectionId: 'connection-2',
|
||||
dbName: 'reporting',
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
directoryTrees: {
|
||||
'dir-bound': [
|
||||
{
|
||||
name: 'report.sql',
|
||||
path: 'D:/sql/bound/report.sql',
|
||||
isDir: false,
|
||||
},
|
||||
{
|
||||
name: 'orders.sql',
|
||||
path: 'D:/sql/bound/orders.sql',
|
||||
isDir: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(node.children?.[0]?.children?.[0]?.dataRef).toMatchObject({
|
||||
connectionId: 'connection-2',
|
||||
dbName: 'reporting',
|
||||
hasExplicitBinding: true,
|
||||
});
|
||||
expect(node.children?.[0]?.children?.[1]?.dataRef).toMatchObject({
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
});
|
||||
expect(node.children?.[0]?.children?.[1]?.dataRef).not.toHaveProperty('hasExplicitBinding');
|
||||
});
|
||||
|
||||
it('keeps same-path directories separate when they target different databases', () => {
|
||||
const node = buildExternalSQLRootNode({
|
||||
directories: [
|
||||
@@ -183,6 +237,120 @@ describe('externalSqlTree helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates file bindings when a file or containing folder moves and removes deleted subtrees', () => {
|
||||
const directory: ExternalSQLDirectory = {
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
createdAt: 1,
|
||||
};
|
||||
const bound = setExternalSQLFileBinding(directory, 'D:\\sql\\scripts\\daily.sql', {
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
});
|
||||
const movedFile = moveExternalSQLFileBindings(
|
||||
bound,
|
||||
'D:/sql/scripts/daily.sql',
|
||||
'D:/sql/scripts/archive/daily.sql',
|
||||
);
|
||||
const movedFolder = moveExternalSQLFileBindings(
|
||||
movedFile,
|
||||
'D:/sql/scripts/archive',
|
||||
'D:/sql/scripts/history',
|
||||
);
|
||||
|
||||
expect(movedFolder.fileBindings).toEqual([{
|
||||
filePath: 'D:/sql/scripts/history/daily.sql',
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
}]);
|
||||
expect(removeExternalSQLFileBindings(movedFolder, 'D:/sql/scripts/history').fileBindings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves only persisted file bindings without replacing directory defaults', () => {
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
fileBindings: [{
|
||||
filePath: 'D:/sql/scripts/report.sql',
|
||||
connectionId: 'connection-2',
|
||||
dbName: 'reporting',
|
||||
}],
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
expect(resolveExternalSQLFileBinding(directories, 'D:\\sql\\scripts\\report.sql')).toEqual({
|
||||
connectionId: 'connection-2',
|
||||
dbName: 'reporting',
|
||||
hasExplicitBinding: true,
|
||||
});
|
||||
expect(resolveExternalSQLFileBinding(
|
||||
directories,
|
||||
'D:/sql/scripts/orders.sql',
|
||||
)).toBeUndefined();
|
||||
expect(resolveExternalSQLFileBinding([{
|
||||
id: 'root-dir',
|
||||
name: 'root',
|
||||
path: '/',
|
||||
fileBindings: [{
|
||||
filePath: '/var/sql/report.sql',
|
||||
connectionId: 'connection-root',
|
||||
dbName: 'main',
|
||||
}],
|
||||
createdAt: 1,
|
||||
}], '/var/sql/report.sql')).toEqual({
|
||||
connectionId: 'connection-root',
|
||||
dbName: 'main',
|
||||
hasExplicitBinding: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes explicit binding lookup to the preferred same-path directory', () => {
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
id: 'dir-orders',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/shared',
|
||||
connectionId: 'connection-1',
|
||||
dbName: 'orders',
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'dir-reporting',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/shared',
|
||||
connectionId: 'connection-2',
|
||||
dbName: 'reporting',
|
||||
fileBindings: [{
|
||||
filePath: 'D:/sql/shared/report.sql',
|
||||
connectionId: 'connection-3',
|
||||
dbName: 'warehouse',
|
||||
}],
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
expect(resolveExternalSQLFileBinding(
|
||||
directories,
|
||||
'D:/sql/shared/report.sql',
|
||||
{ connectionId: 'connection-1', dbName: 'orders' },
|
||||
)).toBeUndefined();
|
||||
expect(resolveExternalSQLFileBinding(
|
||||
directories,
|
||||
'D:/sql/shared/report.sql',
|
||||
{ connectionId: 'connection-2', dbName: 'reporting' },
|
||||
)).toEqual({
|
||||
connectionId: 'connection-3',
|
||||
dbName: 'warehouse',
|
||||
hasExplicitBinding: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters non-sql file entries even when the backend returns them', () => {
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
import type {
|
||||
ExternalSQLDirectory,
|
||||
ExternalSQLFileBinding,
|
||||
ExternalSQLTreeEntry,
|
||||
} from '../types';
|
||||
|
||||
export type ExternalSQLNodeType =
|
||||
| 'external-sql-root'
|
||||
@@ -32,6 +36,141 @@ export type ExternalSQLTreeLabels = {
|
||||
export const normalizeExternalSQLPath = (value: string): string =>
|
||||
String(value || '').trim().replace(/\\/g, '/');
|
||||
|
||||
export const findExternalSQLFileBinding = (
|
||||
bindings: ExternalSQLFileBinding[] | undefined,
|
||||
filePath: string,
|
||||
): ExternalSQLFileBinding | undefined => {
|
||||
const normalizedFilePath = normalizeExternalSQLPath(filePath);
|
||||
if (!normalizedFilePath) return undefined;
|
||||
return bindings?.find(
|
||||
(binding) => normalizeExternalSQLPath(binding.filePath) === normalizedFilePath,
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveExternalSQLFileBinding = (
|
||||
directories: ExternalSQLDirectory[],
|
||||
filePath: string,
|
||||
preferredContext?: { connectionId?: string; dbName?: string },
|
||||
): { connectionId: string; dbName: string; hasExplicitBinding: boolean } | undefined => {
|
||||
const normalizedFilePath = normalizeExternalSQLPath(filePath);
|
||||
if (!normalizedFilePath) return undefined;
|
||||
const matchingDirectories = [...directories]
|
||||
.filter((directory) => {
|
||||
const rawDirectoryPath = normalizeExternalSQLPath(directory.path);
|
||||
const directoryPath = rawDirectoryPath === '/' ? '/' : rawDirectoryPath.replace(/\/+$/u, '');
|
||||
return Boolean(directoryPath) && (
|
||||
directoryPath === '/'
|
||||
? normalizedFilePath.startsWith('/')
|
||||
: normalizedFilePath === directoryPath || normalizedFilePath.startsWith(`${directoryPath}/`)
|
||||
);
|
||||
})
|
||||
.sort((left, right) => (
|
||||
normalizeExternalSQLPath(right.path).length - normalizeExternalSQLPath(left.path).length
|
||||
));
|
||||
const preferredConnectionId = String(preferredContext?.connectionId || '').trim();
|
||||
const preferredDbName = String(preferredContext?.dbName || '').trim();
|
||||
const preferredDirectory = preferredConnectionId
|
||||
? matchingDirectories.find((directory) => (
|
||||
String(directory.connectionId || '').trim() === preferredConnectionId
|
||||
&& (!preferredDbName || String(directory.dbName || '').trim() === preferredDbName)
|
||||
))
|
||||
: undefined;
|
||||
if (preferredDirectory) {
|
||||
const binding = findExternalSQLFileBinding(preferredDirectory.fileBindings, normalizedFilePath);
|
||||
return binding
|
||||
? {
|
||||
connectionId: String(binding.connectionId || '').trim(),
|
||||
dbName: String(binding.dbName || '').trim(),
|
||||
hasExplicitBinding: true,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
for (const directory of matchingDirectories) {
|
||||
const binding = findExternalSQLFileBinding(directory.fileBindings, normalizedFilePath);
|
||||
if (binding) {
|
||||
return {
|
||||
connectionId: String(binding.connectionId || '').trim(),
|
||||
dbName: String(binding.dbName || '').trim(),
|
||||
hasExplicitBinding: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const setExternalSQLFileBinding = (
|
||||
directory: ExternalSQLDirectory,
|
||||
filePath: string,
|
||||
target?: { connectionId: string; dbName: string } | null,
|
||||
): ExternalSQLDirectory => {
|
||||
const normalizedFilePath = normalizeExternalSQLPath(filePath);
|
||||
if (!normalizedFilePath) return directory;
|
||||
const remainingBindings = (directory.fileBindings || []).filter(
|
||||
(binding) => normalizeExternalSQLPath(binding.filePath) !== normalizedFilePath,
|
||||
);
|
||||
const connectionId = String(target?.connectionId || '').trim();
|
||||
const dbName = String(target?.dbName || '').trim();
|
||||
const fileBindings = connectionId && dbName
|
||||
? [...remainingBindings, { filePath: normalizedFilePath, connectionId, dbName }]
|
||||
: remainingBindings;
|
||||
const nextDirectory = { ...directory };
|
||||
if (fileBindings.length > 0) {
|
||||
nextDirectory.fileBindings = fileBindings;
|
||||
} else {
|
||||
delete nextDirectory.fileBindings;
|
||||
}
|
||||
return nextDirectory;
|
||||
};
|
||||
|
||||
const isPathEqualOrInside = (path: string, parentPath: string): boolean => (
|
||||
path === parentPath
|
||||
|| (parentPath === '/' ? path.startsWith('/') : path.startsWith(`${parentPath}/`))
|
||||
);
|
||||
|
||||
export const moveExternalSQLFileBindings = (
|
||||
directory: ExternalSQLDirectory,
|
||||
previousPath: string,
|
||||
nextPath: string,
|
||||
): ExternalSQLDirectory => {
|
||||
const normalizedPreviousPath = normalizeExternalSQLPath(previousPath);
|
||||
const normalizedNextPath = normalizeExternalSQLPath(nextPath);
|
||||
if (!normalizedPreviousPath || !normalizedNextPath || !directory.fileBindings?.length) {
|
||||
return directory;
|
||||
}
|
||||
let changed = false;
|
||||
const fileBindings = directory.fileBindings.map((binding) => {
|
||||
const normalizedBindingPath = normalizeExternalSQLPath(binding.filePath);
|
||||
if (!isPathEqualOrInside(normalizedBindingPath, normalizedPreviousPath)) {
|
||||
return binding;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...binding,
|
||||
filePath: `${normalizedNextPath}${normalizedBindingPath.slice(normalizedPreviousPath.length)}`,
|
||||
};
|
||||
});
|
||||
return changed ? { ...directory, fileBindings } : directory;
|
||||
};
|
||||
|
||||
export const removeExternalSQLFileBindings = (
|
||||
directory: ExternalSQLDirectory,
|
||||
targetPath: string,
|
||||
): ExternalSQLDirectory => {
|
||||
const normalizedTargetPath = normalizeExternalSQLPath(targetPath);
|
||||
if (!normalizedTargetPath || !directory.fileBindings?.length) return directory;
|
||||
const fileBindings = directory.fileBindings.filter((binding) => (
|
||||
!isPathEqualOrInside(normalizeExternalSQLPath(binding.filePath), normalizedTargetPath)
|
||||
));
|
||||
if (fileBindings.length === directory.fileBindings.length) return directory;
|
||||
const nextDirectory = { ...directory };
|
||||
if (fileBindings.length > 0) {
|
||||
nextDirectory.fileBindings = fileBindings;
|
||||
} else {
|
||||
delete nextDirectory.fileBindings;
|
||||
}
|
||||
return nextDirectory;
|
||||
};
|
||||
|
||||
const DEFAULT_EXTERNAL_SQL_TREE_LABELS: ExternalSQLTreeLabels = {
|
||||
root: 'External SQL files',
|
||||
directoryFallback: 'SQL directory',
|
||||
@@ -87,7 +226,13 @@ const isExternalSQLFileEntry = (entry: ExternalSQLTreeEntry): boolean => {
|
||||
|
||||
const mapExternalSQLTreeEntries = (
|
||||
entries: ExternalSQLTreeEntry[],
|
||||
context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string },
|
||||
context: {
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
dbNodeKey: string;
|
||||
directoryId: string;
|
||||
fileBindings?: ExternalSQLFileBinding[];
|
||||
},
|
||||
): ExternalSQLTreeNode[] => entries.flatMap((entry): ExternalSQLTreeNode[] => {
|
||||
const entryPath = normalizeExternalSQLPath(entry.path);
|
||||
if (entry.isDir) {
|
||||
@@ -113,14 +258,23 @@ const mapExternalSQLTreeEntries = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileBinding = findExternalSQLFileBinding(context.fileBindings, entry.path);
|
||||
const connectionId = String(fileBinding?.connectionId || '').trim() || context.connectionId;
|
||||
const dbName = String(fileBinding?.dbName || '').trim() || context.dbName;
|
||||
|
||||
return [{
|
||||
title: entry.name,
|
||||
key: buildExternalSQLNodeKey('external-sql-file', entryPath, context.directoryId),
|
||||
type: 'external-sql-file',
|
||||
isLeaf: true,
|
||||
dataRef: {
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
connectionId,
|
||||
dbName,
|
||||
...(fileBinding ? {
|
||||
directoryConnectionId: context.connectionId,
|
||||
directoryDbName: context.dbName,
|
||||
hasExplicitBinding: true,
|
||||
} : {}),
|
||||
dbNodeKey: context.dbNodeKey,
|
||||
directoryId: context.directoryId,
|
||||
path: entry.path,
|
||||
@@ -154,6 +308,7 @@ export const buildExternalSQLRootNode = ({
|
||||
dbName: directoryDbName,
|
||||
dbNodeKey,
|
||||
directoryId: directory.id,
|
||||
fileBindings: directory.fileBindings,
|
||||
});
|
||||
return {
|
||||
title: resolveDirectoryDisplayName(directory, resolvedLabels),
|
||||
|
||||
Reference in New Issue
Block a user