feat(external-sql): 支持 SQL 文件指定执行数据库

- 为外部 SQL 文件增加连接与数据库单独绑定,并持久化保存
- 统一侧边栏、最近文件、大文件执行、工具栏和 AI 文件检查上下文
- 在文件与目录重命名、删除时迁移或清理绑定
- 按物理路径去重最近 SQL 文件并补充多语言与回归测试
This commit is contained in:
AutumnNazi
2026-08-06 21:46:37 +08:00
parent b3855fa7c8
commit aeb0b5bc07
19 changed files with 1043 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
},
{

View File

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

View File

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

View File

@@ -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[] = [
{

View File

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