feat(datasource): 新增 DuckDB 与 Diros 数据源并补齐 DuckDB 函数管理

- 新增 DuckDB 与 Diros 后端驱动实现并接入数据库工厂
- 前端连接配置补充 DuckDB/Diros 入口及方言映射
- 侧边栏支持 DuckDB Macro 函数列表加载与对象分组展示
- 定义查看器支持 DuckDB 函数定义查询与 DDL 还原
- 后端补充 DuckDB 函数删除分支并限制存储过程操作
This commit is contained in:
Syngnat
2026-02-11 17:25:38 +08:00
parent 69d9a0b11e
commit e01ecfc387
19 changed files with 1035 additions and 60 deletions

View File

@@ -14,6 +14,7 @@ const MAX_TIMEOUT_SECONDS = 3600;
const getDefaultPortByType = (type: string) => {
switch (type) {
case 'mysql': return 3306;
case 'diros': return 9030;
case 'sphinx': return 9306;
case 'postgres': return 5432;
case 'redis': return 6379;
@@ -26,10 +27,13 @@ const getDefaultPortByType = (type: string) => {
case 'highgo': return 5866;
case 'mariadb': return 3306;
case 'vastbase': return 5432;
case 'duckdb': return 0;
default: return 3306;
}
};
const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb';
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@@ -209,9 +213,11 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
return null;
}
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const mysqlDefaultPort = getDefaultPortByType(type);
const parsed = parseMultiHostUri(trimmedUri, 'mysql');
const parsed = parseMultiHostUri(trimmedUri, 'mysql')
|| parseMultiHostUri(trimmedUri, 'diros')
|| parseMultiHostUri(trimmedUri, 'doris');
if (!parsed) {
return null;
}
@@ -242,6 +248,41 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
};
}
if (isFileDatabaseType(type)) {
const tryExtractPath = (uri: string, scheme: string): string | null => {
const parsed = parseMultiHostUri(uri, scheme);
if (!parsed) {
return null;
}
const host = String(parsed.hosts?.[0] || '').trim();
const dbPath = String(parsed.database || '').trim();
if (host && dbPath) {
return `/${host}/${dbPath}`.replace(/\/+/g, '/');
}
if (host) {
return `/${host}`.replace(/\/+/g, '/');
}
if (dbPath) {
return dbPath.startsWith('/') ? dbPath : `/${dbPath}`;
}
return null;
};
const pathFromScheme = tryExtractPath(trimmedUri, type);
if (pathFromScheme) {
return { host: decodeURIComponent(pathFromScheme) };
}
const rawPath = trimmedUri
.replace(/^sqlite:\/\//i, '')
.replace(/^duckdb:\/\//i, '')
.trim();
if (!rawPath) {
return null;
}
return { host: decodeURIComponent(rawPath) };
}
if (type === 'mongodb') {
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
if (!parsed) {
@@ -305,9 +346,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
});
const getUriPlaceholder = () => {
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') {
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
const defaultPort = getDefaultPortByType(dbType);
return `mysql://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
const scheme = dbType === 'diros' ? 'diros' : 'mysql';
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
if (isFileDatabaseType(dbType)) {
return dbType === 'duckdb'
? 'duckdb:///Users/name/demo.duckdb'
: 'sqlite:///Users/name/demo.sqlite';
}
if (dbType === 'mongodb') {
return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256';
@@ -328,7 +375,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: '';
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const primary = toAddress(host, port, defaultPort);
const replicas = values.mysqlTopology === 'replica'
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
@@ -343,7 +390,17 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
}
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
const query = params.toString();
return `mysql://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
const scheme = type === 'diros' ? 'diros' : 'mysql';
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
}
if (isFileDatabaseType(type)) {
const pathText = String(values.host || '').trim();
if (!pathText) {
return `${type}://`;
}
const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`;
return `${type}://${encodeURI(normalizedPath)}`;
}
if (type === 'mongodb') {
@@ -463,7 +520,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
);
const primaryHost = primaryAddress?.host || String(config.host || 'localhost');
const primaryPort = primaryAddress?.port || Number(config.port || defaultPort);
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
@@ -539,7 +596,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const isRedisType = values.type === 'redis';
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
config: config,
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
@@ -710,7 +767,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
? mergedValues.savePassword !== false
: true;
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const replicas = mergedValues.mysqlTopology === 'replica'
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
: [];
@@ -793,7 +850,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
form.setFieldsValue({ type: type });
const defaultPort = getDefaultPortByType(type);
if (type !== 'sqlite' && type !== 'custom') {
if (!isFileDatabaseType(type) && type !== 'custom') {
form.setFieldsValue({
port: defaultPort,
mysqlTopology: 'single',
@@ -817,7 +874,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
setStep(2);
};
const isSqlite = dbType === 'sqlite';
const isFileDb = isFileDatabaseType(dbType);
const isCustom = dbType === 'custom';
const isRedis = dbType === 'redis';
@@ -825,10 +882,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
{ label: '关系型数据库', items: [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
{ key: 'diros', name: 'Diros', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
{ key: 'duckdb', name: 'DuckDB', icon: <FileTextOutlined style={{ fontSize: 24, color: '#f59e0b' }} /> },
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
]},
{ label: '国产数据库', items: [
@@ -988,16 +1047,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="host"
label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"}
label={isFileDb ? "文件路径 (绝对路径)" : "主机地址 (Host)"}
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
style={{ flex: 1 }}
>
<Input
placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"}
placeholder={isFileDb ? (dbType === 'duckdb' ? "/path/to/db.duckdb" : "/path/to/db.sqlite") : "localhost"}
onDoubleClick={requestTest}
/>
</Form.Item>
{!isSqlite && (
{!isFileDb && (
<Form.Item
name="port"
label="端口 (Port)"
@@ -1009,7 +1068,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)}
</div>
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') && (
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
<Select
@@ -1174,7 +1233,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)}
{/* Non-Redis, non-SQLite: username and password */}
{!isSqlite && !isRedis && (
{!isFileDb && !isRedis && (
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="user"
@@ -1209,7 +1268,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</Form.Item>
)}
{!isSqlite && !isRedis && (
{!isFileDb && !isRedis && (
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
@@ -1217,7 +1276,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</Form.Item>
)}
{!isSqlite && (
{!isFileDb && (
<>
<Divider style={{ margin: '12px 0' }} />
<Form.Item name="useSSH" valuePropName="checked" style={{ marginBottom: 0 }}>

View File

@@ -61,7 +61,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const dbType = config.type || '';
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb';
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';

View File

@@ -23,9 +23,11 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String(conn?.config?.driver || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -47,6 +49,55 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
return { schema: '', name: raw };
};
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
};
const parseDuckDBParameterNames = (raw: any): string[] => {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item ?? '').trim())
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
}
const text = String(raw ?? '').trim();
if (!text) return [];
const normalized = text.startsWith('[') && text.endsWith(']')
? text.slice(1, -1)
: text;
return normalized
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
};
const buildDuckDBMacroDDL = (
schemaName: string,
functionName: string,
parametersRaw: any,
macroDefinitionRaw: any
): string => {
const schema = String(schemaName || '').trim();
const name = String(functionName || '').trim();
const macroDefinition = String(macroDefinitionRaw || '').trim();
if (!name || !macroDefinition) return '';
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
const qualifiedName = schema ? `${schema}.${name}` : name;
const isTableMacro = !macroDefinition.startsWith('(');
if (isTableMacro) {
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
}
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
};
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(viewName);
const safeName = escapeSQLLiteral(name);
@@ -81,6 +132,10 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`];
case 'duckdb': {
const schemaRef = schema || 'main';
return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`];
}
default:
return [`-- 暂不支持该数据库类型的视图定义查看`];
}
@@ -120,8 +175,16 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
}
return [`SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`];
}
case 'duckdb': {
const schemaRef = schema || 'main';
const safeSchema = escapeSQLLiteral(schemaRef);
return [
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${safeSchema}' AND function_name = '${safeName}' LIMIT 1`,
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND function_name = '${safeName}' ORDER BY CASE WHEN schema_name = '${safeSchema}' THEN 0 ELSE 1 END, schema_name LIMIT 1`,
];
}
case 'sqlite':
return [`-- SQLite 不支持存储函数/存储过程`];
return [`-- SQLite 不支持函数/存储过程定义管理`];
default:
return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`];
}
@@ -244,6 +307,21 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
// Oracle/DM ALL_SOURCE returns multiple rows, one per line
return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join('');
}
case 'duckdb': {
const row = data[0] as Record<string, any>;
const ddl = buildDuckDBMacroDDL(
String(getCaseInsensitiveRawValue(row, ['schema_name']) || '').trim(),
String(getCaseInsensitiveRawValue(row, ['function_name', 'routine_name', 'name']) || '').trim(),
getCaseInsensitiveRawValue(row, ['parameters']),
getCaseInsensitiveRawValue(row, ['macro_definition'])
);
if (ddl) return ddl;
const fallback = getCaseInsensitiveRawValue(row, ['macro_definition', 'routine_definition', 'definition']);
if (fallback !== undefined && fallback !== null && String(fallback).trim() !== '') {
return String(fallback);
}
return JSON.stringify(row, null, 2);
}
default: {
const row = data[0];
return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || '';

View File

@@ -922,7 +922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
const normalizedType = (dbType || 'mysql').toLowerCase();
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === '';
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === '';
if (!supportsLimit) return { sql, applied: false, maxRows };
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };

View File

@@ -228,9 +228,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String((conn?.config as any)?.driver || '').trim().toLowerCase();
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -283,6 +285,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return '';
};
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
};
const getFirstRowValue = (row: Record<string, any>): string => {
for (const value of Object.values(row || {})) {
if (value !== undefined && value !== null) {
@@ -326,6 +340,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
};
const parseDuckDBParameterNames = (raw: any): string[] => {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item ?? '').trim())
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
}
const text = String(raw ?? '').trim();
if (!text) return [];
const normalized = text.startsWith('[') && text.endsWith(']')
? text.slice(1, -1)
: text;
return normalized
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
};
const buildDuckDBMacroDDL = (
schemaName: string,
functionName: string,
parametersRaw: any,
macroDefinitionRaw: any
): string => {
const schema = String(schemaName || '').trim();
const name = String(functionName || '').trim();
const macroDefinition = String(macroDefinitionRaw || '').trim();
if (!name || !macroDefinition) return '';
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
const qualifiedName = schema ? `${schema}.${name}` : name;
const isTableMacro = !macroDefinition.startsWith('(');
if (isTableMacro) {
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
}
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
};
const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
@@ -358,6 +410,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return [{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` }];
case 'sqlite':
return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }];
case 'duckdb':
return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }];
default:
return [];
}
@@ -395,6 +449,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }];
case 'sqlite':
return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }];
case 'duckdb':
return [];
default:
return [];
}
@@ -438,6 +494,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return [{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }];
}
return [{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }];
case 'duckdb':
return [{
sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`,
inferredType: 'FUNCTION',
}];
default:
return [];
}
@@ -1697,6 +1758,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'sqlite':
query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`;
break;
case 'duckdb': {
const parts = splitQualifiedName(viewName);
const viewSchema = escapeSQLLiteral(parts.schemaName || 'main');
const viewObject = escapeSQLLiteral(parts.objectName || viewName);
query = `SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`;
break;
}
}
if (query) {
const result = await DBQuery(config as any, dbName, query);
@@ -1739,6 +1807,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlite':
case 'duckdb':
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
default:
@@ -1831,9 +1900,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
try {
const config = buildRuntimeConfig(conn, dbName);
let query = '';
const parts = routineName.split('.');
const name = parts.length > 1 ? parts[1] : routineName;
const schema = parts.length > 1 ? parts[0] : '';
const parsedRoutine = splitQualifiedName(routineName);
const name = parsedRoutine.objectName || routineName;
const schema = parsedRoutine.schemaName;
switch (dialect) {
case 'mysql':
@@ -1856,6 +1925,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
break;
}
case 'duckdb': {
const schemaRef = schema || 'main';
query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`;
break;
}
}
if (query) {
const result = await DBQuery(config as any, dbName, query);
@@ -1863,6 +1937,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (dialect === 'oracle' || dialect === 'dm') {
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`;
} else if (dialect === 'duckdb') {
const row = result.data[0] as Record<string, any>;
const ddl = buildDuckDBMacroDDL(
String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(),
String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(),
getCaseInsensitiveRawValue(row, ['parameters']),
getCaseInsensitiveRawValue(row, ['macro_definition'])
);
if (ddl) template = `-- 编辑${typeLabel} ${routineName}\n${ddl}`;
} else {
const row = result.data[0] as Record<string, any>;
const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
@@ -1910,6 +1993,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
break;
case 'duckdb':
template = isProc
? `-- DuckDB 暂不支持存储过程\n-- 请使用 SQL Macro 作为函数能力\nCREATE MACRO func_name(param1) AS (param1 * 2);`
: `CREATE MACRO func_name(param1) AS (param1 * 2);`;
break;
default:
template = isProc
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
@@ -2031,20 +2119,24 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// 函数分组节点的右键菜单
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
return [
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
const routineMenu: MenuProps['items'] = [
{
key: 'create-function',
label: '新建函数',
icon: <PlusOutlined />,
onClick: () => openCreateRoutine(node, 'FUNCTION')
},
{
];
if (dialect !== 'duckdb') {
routineMenu.push({
key: 'create-procedure',
label: '新建存储过程',
icon: <PlusOutlined />,
onClick: () => openCreateRoutine(node, 'PROCEDURE')
},
];
});
}
return routineMenu;
}
if (node.type === 'connection') {

View File

@@ -479,7 +479,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = String(conn?.config?.type || '').toLowerCase();
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};

View File

@@ -50,9 +50,11 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String(conn?.config?.driver || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -100,6 +102,8 @@ LIMIT 1`];
return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`];
case 'duckdb':
return [`-- DuckDB 不支持触发器`];
case 'tdengine':
return [`-- TDengine 不支持触发器`];
case 'mongodb':