feat(starrocks): 新增 StarRocks 数据源与高级对象能力

- 后端接入:新增独立 starrocks 可选驱动,复用 MySQL wire 协议并支持默认 9030 端口
- 驱动管理:补齐 manifest、build tag、revision、driver-agent provider 和构建脚本
- 前端接入:新增 StarRocks 连接类型、图标、能力矩阵、URI 解析、保存回显和 SQL 自动 LIMIT
- 方言增强:新增 StarRocks 类型、关键字、函数补全和专属建表 SQL 生成
- 高级对象:支持物化视图对象浏览、Rollup 模板、外部 Catalog 模板和高级表设计器参数
- CI 发布:将 StarRocks driver-agent 纳入 dev/release 构建与 release 资产校验
This commit is contained in:
Syngnat
2026-05-15 17:30:08 +08:00
parent 2580e4d6f3
commit 569edbb11a
49 changed files with 1164 additions and 62 deletions

View File

@@ -261,7 +261,7 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"

View File

@@ -252,7 +252,7 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -577,6 +577,10 @@ jobs:
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
"drivers/Windows/starrocks-driver-agent-windows-amd64.exe"
"drivers/MacOS/starrocks-driver-agent-darwin-amd64"
"drivers/MacOS/starrocks-driver-agent-darwin-arm64"
"drivers/Linux/starrocks-driver-agent-linux-amd64"
)
missing=0

View File

@@ -39,6 +39,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
| Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing |
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |

View File

@@ -38,6 +38,7 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
@@ -42,7 +42,7 @@ normalize_driver() {
case "$name" in
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
mariadb|oceanbase|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|mongodb|tdengine|clickhouse)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)

View File

@@ -0,0 +1,12 @@
//go:build gonavi_starrocks_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "starrocks"
agentDatabaseFactory = func() db.Database {
return &db.StarRocksDB{}
}
}

View File

@@ -202,6 +202,7 @@ const getDefaultPortByType = (type: string) => {
return 2881;
case "doris":
case "diros":
case "starrocks":
return 9030;
case "sphinx":
return 9306;
@@ -259,6 +260,7 @@ const sslSupportedTypes = new Set([
"oceanbase",
"doris",
"diros",
"starrocks",
"sphinx",
"dameng",
"clickhouse",
@@ -290,6 +292,7 @@ const isMySQLCompatibleType = (type: string) =>
type === "oceanbase" ||
type === "doris" ||
type === "diros" ||
type === "starrocks" ||
type === "sphinx";
const supportsConnectionParamsForType = (type: string) =>
@@ -1359,6 +1362,8 @@ const ConnectionModal: React.FC<{
parseMultiHostUri(trimmedUri, "jdbc:mysql") ||
parseMultiHostUri(trimmedUri, "oceanbase") ||
parseMultiHostUri(trimmedUri, "jdbc:oceanbase") ||
parseMultiHostUri(trimmedUri, "starrocks") ||
parseMultiHostUri(trimmedUri, "jdbc:starrocks") ||
parseMultiHostUri(trimmedUri, "diros") ||
parseMultiHostUri(trimmedUri, "doris");
if (!parsed) {
@@ -1782,7 +1787,7 @@ const ConnectionModal: React.FC<{
if (isMySQLCompatibleType(dbType)) {
const defaultPort = getDefaultPortByType(dbType);
const scheme =
dbType === "diros" ? "doris" : dbType === "oceanbase" ? "oceanbase" : "mysql";
dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : "mysql";
if (dbType === "oceanbase") {
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
}
@@ -1896,7 +1901,7 @@ const ConnectionModal: React.FC<{
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
const query = params.toString();
const scheme =
type === "diros" ? "doris" : type === "oceanbase" ? "oceanbase" : "mysql";
type === "diros" ? "doris" : type === "starrocks" ? "starrocks" : type === "oceanbase" ? "oceanbase" : "mysql";
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
}
@@ -2256,6 +2261,7 @@ const ConnectionModal: React.FC<{
configType === "mariadb" ||
configType === "oceanbase" ||
configType === "diros" ||
configType === "starrocks" ||
configType === "sphinx"
? normalizedHosts.slice(1)
: [];
@@ -3631,6 +3637,11 @@ const ConnectionModal: React.FC<{
name: "Doris",
icon: getDbIcon("diros", undefined, 36),
},
{
key: "starrocks",
name: "StarRocks",
icon: getDbIcon("starrocks", undefined, 36),
},
{
key: "sphinx",
name: "Sphinx",

View File

@@ -50,7 +50,7 @@ const quoteSqlIdent = (dbType: string, ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
const t = String(dbType || '').toLowerCase();
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'starrocks' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
if (t === 'sqlserver') {

View File

@@ -29,6 +29,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
starrocks: '#00A6A6',
sphinx: '#2F5D62',
custom: '#888888',
};
@@ -121,6 +122,9 @@ const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="diros" size={size} color={color} />
);
const StarRocksIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.starrocks} label="SR" />
);
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sphinx" size={size} color={color} />
);
@@ -175,6 +179,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mariadb: MariaDBIcon,
oceanbase: OceanBaseIcon,
diros: DorisIcon,
starrocks: StarRocksIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
@@ -197,7 +202,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'tdengine', 'custom',
];
@@ -218,6 +223,7 @@ export const getDbIconLabel = (type: string): string => {
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
starrocks: 'StarRocks',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',

View File

@@ -120,13 +120,23 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
};
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => {
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string, viewKind?: string): string[] => {
const { schema, name } = parseSchemaAndName(viewName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks':
if (dialect === 'starrocks' && viewKind === 'materialized') {
const mvRef = schema
? `\`${schema.replace(/`/g, '``')}\`.\`${name.replace(/`/g, '``')}\``
: `\`${name.replace(/`/g, '``')}\``;
return [
`SHOW CREATE MATERIALIZED VIEW ${mvRef}`,
`SHOW CREATE TABLE ${mvRef}`,
];
}
return [
`SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``,
safeDbName
@@ -172,6 +182,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
switch (dialect) {
case 'mysql':
case 'starrocks':
return [
`SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``,
safeDbName
@@ -277,7 +288,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const row = data[0];
switch (dialect) {
case 'mysql': {
case 'mysql':
case 'starrocks': {
const keys = Object.keys(row);
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
@@ -305,7 +317,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
switch (dialect) {
case 'mysql': {
case 'mysql':
case 'starrocks': {
const row = data[0];
const keys = Object.keys(row);
if (row.routine_definition || row.ROUTINE_DEFINITION) {
@@ -380,9 +393,9 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
setLoading(false);
return;
}
queries = buildShowViewQueries(dialect, viewName, dbName);
queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind);
extractFn = extractViewDefinition;
objectLabel = '视图';
objectLabel = tab.viewKind === 'materialized' ? '物化视图' : '视图';
} else {
const routineName = tab.routineName || '';
const routineType = tab.routineType || 'FUNCTION';
@@ -443,9 +456,9 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
};
loadDefinition();
}, [tab.connectionId, tab.dbName, tab.viewName, tab.routineName, tab.routineType, tab.type, connections]);
}, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.routineName, tab.routineType, tab.type, connections]);
const objectLabel = tab.type === 'view-def' ? '视图' : '函数/存储过程';
const objectLabel = tab.type === 'view-def' ? (tab.viewKind === 'materialized' ? '物化视图' : '视图') : '函数/存储过程';
const objectName = tab.type === 'view-def' ? tab.viewName : tab.routineName;
if (loading) {

View File

@@ -48,6 +48,7 @@ import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import {
@@ -74,7 +75,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
}
export const resolveSidebarTableNameForCopy = (node: Pick<TreeNode, 'title' | 'dataRef'> | null | undefined): string => {
@@ -841,7 +842,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql': {
case 'mysql':
case 'starrocks': {
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
return normalizeMetadataQuerySpecs([
{
@@ -886,7 +888,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql': {
case 'mysql':
case 'starrocks': {
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
return normalizeMetadataQuerySpecs([
{
@@ -927,6 +930,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks':
return normalizeMetadataQuerySpecs([
{
sql: safeDbName
@@ -1047,6 +1051,46 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return { views, supported: hasSuccessfulQuery };
};
const loadStarRocksMaterializedViews = async (
conn: any,
dbName: string
): Promise<{ views: string[]; supported: boolean }> => {
const dialect = getMetadataDialect(conn as SavedConnection);
if (dialect !== 'starrocks') {
return { views: [], supported: false };
}
const safeDbName = escapeSQLLiteral(dbName);
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
const querySpecs = normalizeMetadataQuerySpecs([
{
sql: safeDbName
? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME`
: '',
},
{ sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' },
{ sql: `SHOW MATERIALIZED VIEWS` },
]);
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
const seen = new Set<string>();
const views: string[] = [];
results.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']);
const viewName =
getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name'])
|| getFirstRowValue(row);
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
if (!fullName || seen.has(fullName)) return;
seen.add(fullName);
views.push(fullName);
});
});
return { views, supported: hasSuccessfulQuery };
};
const loadDatabaseTriggers = async (
conn: any,
dbName: string
@@ -1425,8 +1469,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
const [viewsResult, materializedViewsResult, triggersResult, routinesResult] = await Promise.all([
loadViews(conn, conn.dbName),
loadStarRocksMaterializedViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
]);
@@ -1459,6 +1504,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}));
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
const materializedViewRows: string[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : [];
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : [];
@@ -1471,6 +1517,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
const materializedViewEntries = materializedViewRows.map((viewName: string) => {
const parsed = splitQualifiedName(viewName);
return {
viewName,
schemaName: parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, viewName),
};
});
const triggerEntries = (() => {
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
const triggerSeen = new Set<string>();
@@ -1548,6 +1603,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// Sort views by name (case-insensitive)
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort triggers by display name (case-insensitive)
triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
@@ -1572,6 +1629,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
isLeaf: true,
});
const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-materialized-view-${entry.viewName}`,
icon: <ThunderboltOutlined />,
type: 'materialized-view',
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' },
isLeaf: true,
});
const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`,
@@ -1613,6 +1679,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
schemaName: string;
tables: TreeNode[];
views: TreeNode[];
materializedViews: TreeNode[];
routines: TreeNode[];
triggers: TreeNode[];
};
@@ -1627,6 +1694,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
schemaName,
tables: [],
views: [],
materializedViews: [],
routines: [],
triggers: [],
};
@@ -1637,11 +1705,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
tableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry)));
viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry)));
materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry)));
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
const dialect = getMetadataDialect(conn as SavedConnection);
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
const includeMaterializedViews = dialect === 'starrocks';
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
@@ -1657,6 +1727,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const groupedNodes: TreeNode[] = [
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', '物化视图', <ThunderboltOutlined />, bucket.materializedViews, { schemaName: bucket.schemaName })] : []),
buildObjectGroup(schemaNodeKey, 'routines', '函数', <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'triggers', '触发器', <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
];
@@ -1674,9 +1745,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]);
} else {
const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks';
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
buildObjectGroup(key as string, 'views', '视图', <EyeOutlined />, viewEntries.map(buildViewNode)),
...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', '物化视图', <ThunderboltOutlined />, materializedViewEntries.map(buildMaterializedViewNode))] : []),
buildObjectGroup(key as string, 'routines', '函数', <CodeOutlined />, routineEntries.map(buildRoutineNode)),
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
];
@@ -1719,7 +1792,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const target = resolveSidebarLocateTarget(request, {
groupBySchema: shouldHideSchemaPrefix(conn),
});
const objectLabel = request.objectGroup === 'views' ? '视图' : '表';
const objectLabel = request.objectGroup === 'materializedViews' ? '物化视图' : (request.objectGroup === 'views' ? '视图' : '表');
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
const dbLoadKey = `dbs-${request.connectionId}`;
@@ -1893,7 +1966,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
setActiveContext({ connectionId: dataRef.id, dbName: '' });
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
} else if (type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
@@ -1940,7 +2013,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') setActiveContext({ connectionId: dataRef.id, dbName: '' });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'table' || type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
@@ -1958,7 +2031,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
tableName,
});
return;
} else if (node.type === 'view') {
} else if (node.type === 'view' || node.type === 'materialized-view') {
const { viewName, dbName, id } = node.dataRef;
addTab({
id: node.key,
@@ -2145,7 +2218,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (node.type === 'database') {
connId = node.dataRef.id;
dbName = node.title;
} else if (node.type === 'table' || node.type === 'view') {
} else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
connId = node.dataRef.id;
dbName = node.dataRef.dbName;
}
@@ -3138,13 +3211,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// --- 视图操作 ---
const openViewDefinition = (node: any) => {
const { viewName, dbName, id } = node.dataRef;
const isMaterialized = node.type === 'materialized-view' || node.dataRef?.objectKind === 'materialized-view';
addTab({
id: `view-def-${id}-${dbName}-${viewName}`,
title: `视图: ${viewName}`,
title: `${isMaterialized ? '物化视图' : '视图'}: ${viewName}`,
type: 'view-def',
connectionId: id,
dbName,
viewName,
viewKind: isMaterialized ? 'materialized' : 'view',
});
};
@@ -3160,6 +3235,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
let query = '';
switch (dialect) {
case 'mysql':
case 'starrocks':
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
@@ -3216,6 +3292,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
let template: string;
switch (dialect) {
case 'mysql':
case 'starrocks':
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
@@ -3244,6 +3321,56 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const openCreateStarRocksMaterializedView = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
const schemaPrefix = String(conn.schemaName || dbName || '').trim();
const mvName = schemaPrefix ? `${schemaPrefix}.mv_name` : 'mv_name';
const template = buildStarRocksMaterializedViewPreviewSql({
name: mvName,
query: 'SELECT\n column1,\n COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1',
distributionColumnNames: ['column1'],
refreshClause: 'REFRESH ASYNC',
properties: '"replication_num" = "1"',
});
addTab({
id: `query-create-starrocks-mv-${Date.now()}`,
title: '新建物化视图',
type: 'query',
connectionId: id,
dbName,
query: template,
});
};
const openCreateStarRocksExternalCatalog = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
addTab({
id: `query-create-starrocks-catalog-${Date.now()}`,
title: '新建外部 Catalog',
type: 'query',
connectionId: id,
dbName,
query: `CREATE EXTERNAL CATALOG catalog_name\nPROPERTIES (\n "type" = "hive",\n "hive.metastore.uris" = "thrift://127.0.0.1:9083"\n);`,
});
};
const openCreateStarRocksRollup = (node: any) => {
const conn = node.dataRef;
const { tableName, dbName, id } = conn;
const safeTable = String(tableName || 'table_name').trim();
const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
addTab({
id: `query-create-starrocks-rollup-${Date.now()}`,
title: '新增 Rollup',
type: 'query',
connectionId: id,
dbName,
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
});
};
const handleDropView = (node: any) => {
const conn = node.dataRef;
const viewName = String(conn.viewName || '').trim();
@@ -3327,6 +3454,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
switch (dialect) {
case 'mysql':
case 'starrocks':
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
@@ -3395,6 +3523,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
switch (dialect) {
case 'mysql':
case 'starrocks':
template = isProc
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
@@ -3626,6 +3755,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const isObjectNode = (node: TreeNode): boolean => {
return node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view'
|| node.type === 'db-trigger'
|| node.type === 'routine'
|| node.type === 'object-group';
@@ -3740,6 +3870,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
];
}
if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') {
return [
{
key: 'create-materialized-view',
label: '新建物化视图',
icon: <PlusOutlined />,
onClick: () => openCreateStarRocksMaterializedView(node)
},
];
}
// 函数分组节点的右键菜单
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
@@ -4107,6 +4248,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
];
} else if (node.type === 'database') {
const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks';
return [
{
key: 'new-table',
@@ -4114,6 +4256,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <TableOutlined />,
onClick: () => openNewTableDesign(node)
},
...(isStarRocks ? [
{
key: 'new-materialized-view',
label: '新建物化视图',
icon: <ThunderboltOutlined />,
onClick: () => openCreateStarRocksMaterializedView(node)
},
{
key: 'new-external-catalog',
label: '新建外部 Catalog',
icon: <CloudOutlined />,
onClick: () => openCreateStarRocksExternalCatalog(node)
},
] : []),
{
key: 'rename-db',
label: '重命名数据库',
@@ -4263,6 +4419,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
]
},
];
} else if (node.type === 'materialized-view') {
return [
{
key: 'open-materialized-view',
label: '浏览物化视图数据',
icon: <EyeOutlined />,
onClick: () => onDoubleClick(null, node)
},
{
key: 'materialized-view-definition',
label: '查看物化视图定义',
icon: <CodeOutlined />,
onClick: () => openViewDefinition(node)
},
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || ''))
});
}
},
];
} else if (node.type === 'routine') {
const routineType = node.dataRef?.routineType || 'FUNCTION';
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
@@ -4296,6 +4482,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
];
} else if (node.type === 'table') {
const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks';
return [
{
key: 'new-query',
@@ -4321,6 +4508,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <EditOutlined />,
onClick: () => openDesign(node, 'columns', false)
},
...(isStarRocks ? [{
key: 'new-rollup',
label: '新增 Rollup',
icon: <ThunderboltOutlined />,
onClick: () => openCreateStarRocksRollup(node)
}] : []),
{
key: 'copy-table-name',
label: '复制表名',
@@ -4507,7 +4700,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const displayTitle = String(node.title ?? '');
let hoverTitle = displayTitle;
if (node.type === 'table' || node.type === 'view') {
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim();
const conn = node?.dataRef as SavedConnection | undefined;
if (rawTableName && shouldHideSchemaPrefix(conn)) {

View File

@@ -9,7 +9,7 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges, type StarRocksCreateTableOptions, type StarRocksDistributionType, type StarRocksKeyModel, type StarRocksTableKind } from './tableDesignerSchemaSql';
import TableDesignerSqlPreview from './TableDesignerSqlPreview';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
@@ -367,6 +367,18 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [newTableName, setNewTableName] = useState('');
const [charset, setCharset] = useState('utf8mb4');
const [collation, setCollation] = useState('utf8mb4_unicode_ci');
const [starRocksTableKind, setStarRocksTableKind] = useState<StarRocksTableKind>('olap');
const [starRocksKeyModel, setStarRocksKeyModel] = useState<StarRocksKeyModel>('DUPLICATE');
const [starRocksKeyColumns, setStarRocksKeyColumns] = useState<string[]>([]);
const [starRocksPartitionClause, setStarRocksPartitionClause] = useState('');
const [starRocksDistributionType, setStarRocksDistributionType] = useState<StarRocksDistributionType>('HASH');
const [starRocksDistributionColumns, setStarRocksDistributionColumns] = useState<string[]>([]);
const [starRocksBucketMode, setStarRocksBucketMode] = useState<'AUTO' | 'NUMBER'>('AUTO');
const [starRocksBucketCount, setStarRocksBucketCount] = useState('');
const [starRocksProperties, setStarRocksProperties] = useState('');
const [starRocksRollups, setStarRocksRollups] = useState('');
const [starRocksExternalEngine, setStarRocksExternalEngine] = useState('hive');
const [starRocksExternalProperties, setStarRocksExternalProperties] = useState('"resource" = "hive0"\n"database" = "raw_db"\n"table" = "raw_table"');
const [loading, setLoading] = useState(false);
const [previewSql, setPreviewSql] = useState<string>('');
@@ -849,11 +861,11 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|| customDriver === 'sphinx'
|| customDriver === 'tidb'
|| customDriver === 'oceanbase'
|| customDriver === 'starrocks'
|| customDriver.includes('mysql')
) {
return 'mysql';
}
if (customDriver === 'starrocks') return 'starrocks';
if (customDriver === 'dameng') return 'dm';
return customDriver;
};
@@ -876,6 +888,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
case 'mariadb':
case 'oceanbase':
case 'diros':
case 'starrocks':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
@@ -938,6 +951,7 @@ END;`;
case 'mariadb':
case 'oceanbase':
case 'diros':
case 'starrocks':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
@@ -1309,6 +1323,43 @@ ${selectedTrigger.statement}`;
[columns]
);
const isStarRocksNewTable = isNewTable && getDbType() === 'starrocks';
const parseStarRocksRollupOptions = (raw: string): StarRocksCreateTableOptions['rollups'] => (
String(raw || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [namePart, columnsPart] = line.split(':');
const name = String(namePart || '').trim();
const columnNames = String(columnsPart || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
return { name, columnNames };
})
.filter(item => item.name && item.columnNames.length > 0)
);
const buildStarRocksCreateOptions = (): StarRocksCreateTableOptions | undefined => {
if (!isStarRocksNewTable) return undefined;
return {
tableKind: starRocksTableKind,
keyModel: starRocksKeyModel,
keyColumnNames: starRocksKeyColumns,
partitionClause: starRocksPartitionClause,
distributionType: starRocksDistributionType,
distributionColumnNames: starRocksDistributionColumns,
bucketMode: starRocksBucketMode,
bucketCount: Number(starRocksBucketCount) || undefined,
properties: starRocksProperties,
rollups: parseStarRocksRollupOptions(starRocksRollups),
externalEngine: starRocksExternalEngine,
externalProperties: starRocksExternalProperties,
};
};
useEffect(() => {
if (selectedIndexKeys.length === 0) return;
const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key));
@@ -1489,6 +1540,7 @@ ${selectedTrigger.statement}`;
columns: targetColumns,
charset: targetCharset,
collation: targetCollation,
starRocksOptions: buildStarRocksCreateOptions(),
});
};
@@ -2350,6 +2402,134 @@ END;`;
})),
];
const starRocksAdvancedTabContent = (
<div style={{ height: '100%', overflow: 'auto', padding: 12 }}>
<Space direction="vertical" size={14} style={{ width: '100%', maxWidth: 960 }}>
<Radio.Group
value={starRocksTableKind}
onChange={(e) => setStarRocksTableKind(e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{ label: 'OLAP 表', value: 'olap' },
{ label: '外部表', value: 'external' },
]}
/>
{starRocksTableKind === 'olap' ? (
<>
<Space wrap>
<Select
value={starRocksKeyModel}
onChange={setStarRocksKeyModel}
options={[
{ label: 'Duplicate Key', value: 'DUPLICATE' },
{ label: 'Primary Key', value: 'PRIMARY' },
{ label: 'Unique Key', value: 'UNIQUE' },
{ label: 'Aggregate Key', value: 'AGGREGATE' },
]}
style={{ width: 180 }}
/>
<Select
mode="multiple"
allowClear
placeholder="Key 字段"
value={starRocksKeyColumns}
onChange={setStarRocksKeyColumns}
options={localColumnOptions}
style={{ minWidth: 280 }}
/>
</Space>
<Input.TextArea
value={starRocksPartitionClause}
onChange={(e) => setStarRocksPartitionClause(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'PARTITION BY date_trunc(\'day\', `event_time`)\n-- 或按业务需要填写完整 PARTITION BY 子句'}
/>
<Space wrap>
<Select
value={starRocksDistributionType}
onChange={setStarRocksDistributionType}
options={[
{ label: 'Hash 分桶', value: 'HASH' },
{ label: 'Random 分桶', value: 'RANDOM' },
{ label: '不生成分桶子句', value: 'NONE' },
]}
style={{ width: 180 }}
/>
<Select
mode="multiple"
allowClear
disabled={starRocksDistributionType !== 'HASH'}
placeholder="分桶字段"
value={starRocksDistributionColumns}
onChange={setStarRocksDistributionColumns}
options={localColumnOptions}
style={{ minWidth: 260 }}
/>
<Select
value={starRocksBucketMode}
onChange={setStarRocksBucketMode}
options={[
{ label: 'Buckets Auto', value: 'AUTO' },
{ label: '指定 Buckets', value: 'NUMBER' },
]}
style={{ width: 160 }}
/>
<Input
{...noAutoCapInputProps}
disabled={starRocksBucketMode !== 'NUMBER'}
value={starRocksBucketCount}
onChange={(e) => setStarRocksBucketCount(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Buckets"
style={{ width: 120 }}
/>
</Space>
<Input.TextArea
value={starRocksProperties}
onChange={(e) => setStarRocksProperties(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'"replication_num" = "1"\n"storage_medium" = "SSD"'}
/>
<Input.TextArea
value={starRocksRollups}
onChange={(e) => setStarRocksRollups(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'rollup_name: column1, column2\nrollup_daily: dt, user_id'}
/>
</>
) : (
<>
<Space wrap>
<Select
value={starRocksExternalEngine}
onChange={setStarRocksExternalEngine}
options={[
{ label: 'Hive', value: 'hive' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'Iceberg', value: 'iceberg' },
{ label: 'Hudi', value: 'hudi' },
{ label: 'JDBC', value: 'jdbc' },
]}
style={{ width: 180 }}
/>
</Space>
<Input.TextArea
value={starRocksExternalProperties}
onChange={(e) => setStarRocksExternalProperties(e.target.value)}
autoSize={{ minRows: 6, maxRows: 14 }}
placeholder={'"resource" = "hive0"\n"database" = "raw_db"\n"table" = "raw_table"'}
/>
</>
)}
</Space>
</div>
);
const columnsTabContent = (
<div
ref={containerRef}
@@ -2593,6 +2773,13 @@ END;`;
label: '字段',
children: columnsTabContent
},
...(isStarRocksNewTable ? [
{
key: 'starrocks',
label: 'StarRocks',
children: starRocksAdvancedTabContent,
},
] : []),
...(!isNewTable ? [
{
key: 'indexes',

View File

@@ -72,6 +72,7 @@ const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: strin
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
case 'starrocks':
return `
SELECT
TABLE_NAME AS table_name,

View File

@@ -53,6 +53,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks':
return [
`SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``,
safeDbName
@@ -161,7 +162,8 @@ LIMIT 1`];
const row = data[0];
switch (dialect) {
case 'mysql': {
case 'mysql':
case 'starrocks': {
// MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ...
const keys = Object.keys(row);
if (row.trigger_definition || row.TRIGGER_DEFINITION) {

View File

@@ -25,6 +25,8 @@ const resolveCustomDriverDialect = (driver: string): string => {
case 'diros':
case 'doris':
return 'diros';
case 'starrocks':
return 'starrocks';
case 'oceanbase':
return 'oceanbase';
case 'kingbase':
@@ -49,6 +51,7 @@ const resolveCustomDriverDialect = (driver: string): string => {
if (normalized.includes('sqlite')) return 'sqlite';
if (normalized.includes('sphinx')) return 'sphinx';
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
if (normalized.includes('starrocks')) return 'starrocks';
return normalized;
};
@@ -65,6 +68,7 @@ export const supportsTableTruncateAction = (type: string, driver?: string): bool
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'starrocks':
case 'postgres':
case 'kingbase':
case 'highgo':

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildCreateTablePreviewSql,
buildAlterTablePreviewSql,
buildStarRocksMaterializedViewPreviewSql,
hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
type EditableColumnSnapshot,
@@ -218,6 +219,88 @@ describe('tableDesignerSchemaSql', () => {
expect(tdengineSql).not.toContain('AFTER');
});
it('builds StarRocks create table preview with OLAP engine and conservative distribution', () => {
const sql = buildCreateTablePreviewSql({
tableName: 'sales.orders',
dbType: 'starrocks',
columns: [
baseColumn({ _key: 'id', name: 'id', type: 'BIGINT', nullable: 'NO', key: 'PRI' }),
baseColumn({ _key: 'amount', name: 'amount', type: 'DECIMAL(10,2)', nullable: 'YES' }),
],
});
expect(sql).toContain('CREATE TABLE `sales`.`orders`');
expect(sql).toContain('ENGINE=OLAP');
expect(sql).toContain('DUPLICATE KEY (`id`)');
expect(sql).toContain('DISTRIBUTED BY HASH(`id`) BUCKETS AUTO');
expect(sql).not.toContain('ENGINE=InnoDB');
});
it('builds StarRocks advanced OLAP table preview with key model, partition, buckets, properties and rollup', () => {
const sql = buildCreateTablePreviewSql({
tableName: 'sales.events',
dbType: 'starrocks',
columns: [
baseColumn({ _key: 'dt', name: 'dt', type: 'DATE', nullable: 'NO' }),
baseColumn({ _key: 'user_id', name: 'user_id', type: 'BIGINT', nullable: 'NO' }),
baseColumn({ _key: 'amount', name: 'amount', type: 'DECIMAL(10,2)', nullable: 'YES', extra: 'SUM' }),
],
starRocksOptions: {
keyModel: 'AGGREGATE',
keyColumnNames: ['dt', 'user_id'],
partitionClause: 'PARTITION BY date_trunc(\'day\', `dt`)',
distributionColumnNames: ['user_id'],
bucketMode: 'NUMBER',
bucketCount: 12,
properties: '"replication_num" = "1"',
rollups: [{ name: 'rollup_dt', columnNames: ['dt', 'amount'] }],
},
});
expect(sql).toContain('AGGREGATE KEY (`dt`, `user_id`)');
expect(sql).toContain("PARTITION BY date_trunc('day', `dt`)");
expect(sql).toContain('DISTRIBUTED BY HASH(`user_id`) BUCKETS 12');
expect(sql).toContain('PROPERTIES (');
expect(sql).toContain('ALTER TABLE `sales`.`events`\nADD ROLLUP `rollup_dt` (`dt`, `amount`);');
});
it('builds StarRocks external table preview with external engine and properties', () => {
const sql = buildCreateTablePreviewSql({
tableName: 'ext.raw_orders',
dbType: 'starrocks',
columns: [
baseColumn({ _key: 'id', name: 'id', type: 'BIGINT', nullable: 'NO' }),
baseColumn({ _key: 'payload', name: 'payload', type: 'STRING', nullable: 'YES' }),
],
starRocksOptions: {
tableKind: 'external',
externalEngine: 'hive',
externalProperties: '"resource" = "hive0"\n"database" = "ods"\n"table" = "orders"',
},
});
expect(sql).toContain('CREATE EXTERNAL TABLE `ext`.`raw_orders`');
expect(sql).toContain('ENGINE=HIVE');
expect(sql).toContain('"resource" = "hive0"');
expect(sql).not.toContain('ENGINE=OLAP');
});
it('builds StarRocks materialized view preview with refresh and distribution clauses', () => {
const sql = buildStarRocksMaterializedViewPreviewSql({
name: 'sales.mv_user_amount',
query: 'SELECT user_id, SUM(amount) AS total_amount FROM sales.events GROUP BY user_id',
distributionColumnNames: ['user_id'],
bucketCount: 8,
refreshClause: 'REFRESH SCHEDULE EVERY(INTERVAL 10 MINUTE)',
properties: '"replication_num" = "1"',
});
expect(sql).toContain('CREATE MATERIALIZED VIEW `sales`.`mv_user_amount`');
expect(sql).toContain('REFRESH SCHEDULE EVERY(INTERVAL 10 MINUTE)');
expect(sql).toContain('DISTRIBUTED BY HASH(`user_id`) BUCKETS 8');
expect(sql).toContain('AS\nSELECT user_id, SUM(amount) AS total_amount FROM sales.events GROUP BY user_id;');
});
it('treats mariadb and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'sphinx']) {
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));

View File

@@ -36,6 +36,46 @@ export interface BuildCreateTablePreviewInput {
columns: EditableColumnSnapshot[];
charset?: string;
collation?: string;
starRocksOptions?: StarRocksCreateTableOptions;
}
export type StarRocksTableKind = 'olap' | 'external';
export type StarRocksKeyModel = 'DUPLICATE' | 'PRIMARY' | 'UNIQUE' | 'AGGREGATE';
export type StarRocksDistributionType = 'HASH' | 'RANDOM' | 'NONE';
export interface StarRocksRollupOption {
name: string;
columnNames: string[];
fromIndexName?: string;
properties?: string;
}
export interface StarRocksCreateTableOptions {
tableKind?: StarRocksTableKind;
keyModel?: StarRocksKeyModel;
keyColumnNames?: string[];
partitionClause?: string;
distributionType?: StarRocksDistributionType;
distributionColumnNames?: string[];
bucketMode?: 'AUTO' | 'NUMBER';
bucketCount?: number;
properties?: string;
rollups?: StarRocksRollupOption[];
externalEngine?: string;
externalProperties?: string;
}
export interface BuildStarRocksMaterializedViewPreviewInput {
name: string;
query: string;
async?: boolean;
comment?: string;
distributionColumnNames?: string[];
bucketCount?: number;
refreshClause?: string;
partitionClause?: string;
orderByColumnNames?: string[];
properties?: string;
}
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
@@ -156,6 +196,20 @@ const buildDorisColumnDefinition = (column: EditableColumnSnapshot, dbType: stri
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const buildStarRocksColumnDefinition = (column: EditableColumnSnapshot): string => {
const defaultSql = buildDefaultSql(column.default, 'starrocks');
const extraText = String(column.extra || '').trim().toUpperCase();
const aggregateSql = DORIS_AGG_TYPES.has(extraText) ? extraText : '';
return [
quoteIdentifierPart(column.name, 'starrocks'),
String(column.type || '').trim(),
aggregateSql,
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
defaultSql,
`COMMENT '${escapeSqlString(column.comment || '')}'`,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const buildStandardColumnDefinition = (
column: EditableColumnSnapshot,
dbType: string,
@@ -607,6 +661,7 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
if (dbType === 'diros') return buildDorisAlterPreviewSql({ ...input, dbType }, dbType);
if (dbType === 'starrocks') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'StarRocks');
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);
@@ -647,8 +702,150 @@ const buildCreateColumnComments = (tableRef: string, input: BuildCreateTablePrev
.filter(Boolean)
);
const normalizeStarRocksKeyModel = (value: unknown): StarRocksKeyModel => {
const normalized = String(value || '').trim().toUpperCase();
if (normalized === 'PRIMARY' || normalized === 'UNIQUE' || normalized === 'AGGREGATE') return normalized;
return 'DUPLICATE';
};
const normalizeStarRocksDistributionType = (value: unknown): StarRocksDistributionType => {
const normalized = String(value || '').trim().toUpperCase();
if (normalized === 'RANDOM' || normalized === 'NONE') return normalized;
return 'HASH';
};
const pickStarRocksKeyColumns = (
input: BuildCreateTablePreviewInput,
options: StarRocksCreateTableOptions,
): string[] => {
const requested = Array.isArray(options.keyColumnNames) ? options.keyColumnNames : [];
const fallback = input.columns.filter((column) => column.key === 'PRI').map((column) => column.name);
const source = requested.length > 0 ? requested : (fallback.length > 0 ? fallback : input.columns.slice(0, 1).map((column) => column.name));
return source.map((columnName) => String(columnName || '').trim()).filter(Boolean);
};
const quoteStarRocksColumnList = (columnNames: string[]): string => (
columnNames.map((columnName) => quoteIdentifierPart(columnName, 'starrocks')).filter(Boolean).join(', ')
);
const normalizeStarRocksPropertiesBlock = (raw: unknown): string => {
const lines = String(raw || '')
.split(/\r?\n/)
.map((line) => line.trim().replace(/,+$/, ''))
.filter(Boolean);
if (lines.length === 0) return '';
return `PROPERTIES (\n ${lines.join(',\n ')}\n)`;
};
const buildStarRocksDistributionSql = (
input: BuildCreateTablePreviewInput,
options: StarRocksCreateTableOptions,
keyColumns: string[],
): string => {
const distributionType = normalizeStarRocksDistributionType(options.distributionType);
if (distributionType === 'NONE') return '';
if (distributionType === 'RANDOM') {
return options.bucketMode === 'NUMBER' && Number(options.bucketCount) > 0
? `DISTRIBUTED BY RANDOM BUCKETS ${Number(options.bucketCount)}`
: 'DISTRIBUTED BY RANDOM BUCKETS AUTO';
}
const requested = Array.isArray(options.distributionColumnNames) ? options.distributionColumnNames : [];
const distributionColumns = requested.length > 0 ? requested : keyColumns;
const columnList = quoteStarRocksColumnList(
distributionColumns.length > 0 ? distributionColumns : input.columns.slice(0, 1).map((column) => column.name)
);
if (!columnList) return '';
const bucketSql = options.bucketMode === 'NUMBER' && Number(options.bucketCount) > 0
? `BUCKETS ${Number(options.bucketCount)}`
: 'BUCKETS AUTO';
return `DISTRIBUTED BY HASH(${columnList}) ${bucketSql}`;
};
const buildStarRocksRollupSql = (tableRef: string, rollups: StarRocksRollupOption[] | undefined): string[] => (
(Array.isArray(rollups) ? rollups : [])
.map((rollup) => {
const rollupName = String(rollup?.name || '').trim();
const columnList = quoteStarRocksColumnList(Array.isArray(rollup?.columnNames) ? rollup.columnNames : []);
if (!rollupName || !columnList) return '';
const fromSql = String(rollup.fromIndexName || '').trim()
? ` FROM ${quoteIdentifierPart(String(rollup.fromIndexName || '').trim(), 'starrocks')}`
: '';
const propertiesSql = normalizeStarRocksPropertiesBlock(rollup.properties);
const suffix = propertiesSql ? `\n${propertiesSql}` : '';
return `ALTER TABLE ${tableRef}\nADD ROLLUP ${quoteIdentifierPart(rollupName, 'starrocks')} (${columnList})${fromSql}${suffix};`;
})
.filter(Boolean)
);
const buildStarRocksCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => {
const options = input.starRocksOptions || {};
const tableRef = quoteIdentifierPath(input.tableName, 'starrocks');
const colDefs = input.columns.map((column) => buildStarRocksColumnDefinition(column));
const createPrefix = options.tableKind === 'external' ? 'CREATE EXTERNAL TABLE' : 'CREATE TABLE';
const createSql = `${createPrefix} ${tableRef} (\n ${colDefs.join(',\n ')}\n)`;
if (options.tableKind === 'external') {
const engine = String(options.externalEngine || 'hive').trim().toUpperCase();
const propertiesSql = normalizeStarRocksPropertiesBlock(options.externalProperties || options.properties);
return `${createSql}\nENGINE=${engine}${propertiesSql ? `\n${propertiesSql}` : ''};`;
}
const keyModel = normalizeStarRocksKeyModel(options.keyModel);
const keyColumns = pickStarRocksKeyColumns(input, options);
const keyColumnSql = quoteStarRocksColumnList(keyColumns);
const keySql = keyColumnSql ? `${keyModel} KEY (${keyColumnSql})` : '';
const partitionSql = String(options.partitionClause || '').trim().replace(/;+\s*$/, '');
const distributionSql = buildStarRocksDistributionSql(input, options, keyColumns);
const propertiesSql = normalizeStarRocksPropertiesBlock(options.properties);
const clauses = [
'ENGINE=OLAP',
keySql,
partitionSql,
distributionSql,
propertiesSql,
].filter(Boolean);
const createStatement = `${createSql}\n${clauses.join('\n')};`;
const rollupStatements = buildStarRocksRollupSql(tableRef, options.rollups);
return [createStatement, ...rollupStatements].join('\n');
};
export const buildStarRocksMaterializedViewPreviewSql = (
input: BuildStarRocksMaterializedViewPreviewInput,
): string => {
const name = quoteIdentifierPath(input.name || 'mv_name', 'starrocks');
const query = String(input.query || '').trim().replace(/;+\s*$/, '') || 'SELECT column1, COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1';
const commentSql = String(input.comment || '').trim() ? `\nCOMMENT '${escapeSqlString(String(input.comment || '').trim())}'` : '';
const refreshSql = String(input.refreshClause || '').trim()
|| (input.async === false ? 'REFRESH MANUAL' : 'REFRESH ASYNC');
const partitionSql = String(input.partitionClause || '').trim().replace(/;+\s*$/, '');
const distributionColumns = quoteStarRocksColumnList(Array.isArray(input.distributionColumnNames) ? input.distributionColumnNames : []);
const distributionSql = distributionColumns
? `DISTRIBUTED BY HASH(${distributionColumns}) BUCKETS ${Number(input.bucketCount) > 0 ? Number(input.bucketCount) : 'AUTO'}`
: '';
const orderByColumns = quoteStarRocksColumnList(Array.isArray(input.orderByColumnNames) ? input.orderByColumnNames : []);
const orderBySql = orderByColumns ? `ORDER BY (${orderByColumns})` : '';
const propertiesSql = normalizeStarRocksPropertiesBlock(input.properties);
return [
`CREATE MATERIALIZED VIEW ${name}${commentSql}`,
refreshSql,
partitionSql,
distributionSql,
orderBySql,
propertiesSql,
'AS',
`${query};`,
].filter(Boolean).join('\n');
};
export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
if (dbType === 'starrocks') {
return buildStarRocksCreateTablePreviewSql({ ...input, dbType });
}
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const colDefs = input.columns.map((column) => buildCreateTableColumnDefinition(column, dbType));
const pkColumns = input.columns.filter((column) => column.key === 'PRI');

View File

@@ -240,6 +240,28 @@ describe('store appearance persistence', () => {
);
});
it('keeps StarRocks saved connections as independent datasource type', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'starrocks-fe',
name: 'StarRocks FE',
config: {
id: 'starrocks-fe',
type: 'starrocks',
host: 'starrocks.local',
port: 9030,
user: 'root',
},
},
]);
const config = useStore.getState().connections[0]?.config;
expect(config?.type).toBe('starrocks');
expect(config?.port).toBe(9030);
});
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();

View File

@@ -110,6 +110,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
"oceanbase",
"doris",
"diros",
"starrocks",
"sphinx",
"clickhouse",
"postgres",
@@ -133,6 +134,7 @@ const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"dameng",
"clickhouse",
@@ -159,6 +161,7 @@ const getDefaultPortByType = (type: string): number => {
return 2881;
case "doris":
case "diros":
case "starrocks":
return 9030;
case "duckdb":
return 0;

View File

@@ -419,6 +419,7 @@ export interface TabData {
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
viewKind?: "view" | "materialized";
routineName?: string; // Routine name for function/procedure definition tabs
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
savedQueryId?: string; // Saved query identity for quick-save behavior

View File

@@ -71,6 +71,7 @@ describe('connectionModalPresentation', () => {
'oceanbase',
'doris',
'diros',
'starrocks',
'sphinx',
'clickhouse',
'postgres',

View File

@@ -59,6 +59,7 @@ const mysqlCompatibleTypes = new Set([
'oceanbase',
'doris',
'diros',
'starrocks',
'sphinx',
]);
const postgresCompatibleTypes = new Set([

View File

@@ -30,6 +30,16 @@ describe('dataSourceCapabilities', () => {
});
});
it('keeps StarRocks as an independent SQL datasource capability', () => {
expect(getDataSourceCapabilities({ type: 'starrocks' })).toMatchObject({
type: 'starrocks',
supportsQueryEditor: true,
supportsSqlQueryExport: true,
supportsCopyInsert: true,
preferManualTotalCount: false,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',

View File

@@ -8,6 +8,8 @@ const normalizeDataSourceToken = (raw: string): string => {
switch (normalized) {
case 'doris':
return 'diros';
case 'starrocks':
return 'starrocks';
case 'postgresql':
return 'postgres';
case 'opengauss':
@@ -42,6 +44,7 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
'mariadb',
'oceanbase',
'diros',
'starrocks',
'sphinx',
'postgres',
'kingbase',
@@ -62,6 +65,7 @@ const COPY_INSERT_TYPES = new Set([
'mariadb',
'oceanbase',
'diros',
'starrocks',
'sphinx',
'postgres',
'kingbase',

View File

@@ -7,4 +7,4 @@ export const DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP =
'行内“导入驱动包”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`),不支持直接导入 JDBC Jar批量导入请使用上方“导入驱动目录”。';
export const CUSTOM_CONNECTION_DRIVER_HELP =
'已支持: mysql, oceanbase, postgres, opengauss, sqlite, oracle, dm, kingbase别名支持 postgresql/pgx、open_gauss/open-gauss、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';
'已支持: mysql, starrocks, oceanbase, postgres, opengauss, sqlite, oracle, dm, kingbase别名支持 postgresql/pgx、open_gauss/open-gauss、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';

View File

@@ -9,6 +9,7 @@ describe('applyQueryAutoLimit', () => {
'oceanbase',
'diros',
'doris',
'starrocks',
'sphinx',
'postgres',
'postgresql',
@@ -60,6 +61,7 @@ describe('applyQueryAutoLimit', () => {
['mssql', 'SELECT TOP 500 * FROM users'],
['postgresql', 'SELECT * FROM users LIMIT 500'],
['doris', 'SELECT * FROM users LIMIT 500'],
['starrocks', 'SELECT * FROM users LIMIT 500'],
['sqlite3', 'SELECT * FROM users LIMIT 500'],
])('uses custom driver dialect %s', (driver, expected) => {
expect(applyQueryAutoLimit('SELECT * FROM users', 'custom', 500, driver).sql)

View File

@@ -96,6 +96,28 @@ describe('sidebarLocate', () => {
})).toBeNull();
});
it('keeps StarRocks materialized view tabs on the materialized views branch', () => {
const request = normalizeSidebarLocateObjectRequestFromTab({
id: 'view-def-conn-1-main-sales.mv_daily',
type: 'view-def',
connectionId: 'conn-1',
dbName: 'main',
viewName: 'sales.mv_daily',
viewKind: 'materialized',
});
expect(request).toMatchObject({
tableName: 'sales.mv_daily',
schemaName: 'sales',
objectGroup: 'materializedViews',
});
expect(resolveSidebarLocateTarget(request!, { groupBySchema: true })).toMatchObject({
targetKey: 'view-def-conn-1-main-sales.mv_daily',
objectGroupKey: 'conn-1-main-schema-sales-materializedViews',
});
});
it('finds a locate path from loaded tree data even when the target key is absent', () => {
const target = resolveSidebarLocateTarget(
{

View File

@@ -1,4 +1,4 @@
export type SidebarLocateObjectGroup = 'tables' | 'views';
export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews';
export interface SidebarLocateObjectRequest {
tabId?: string;
@@ -37,6 +37,7 @@ export interface SidebarLocateTabLike {
dbName?: string;
tableName?: string;
viewName?: string;
viewKind?: string;
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
@@ -55,12 +56,15 @@ export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName:
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateObjectGroup => {
const explicitGroup = toTrimmedString(detail.objectGroup);
if (explicitGroup === 'views' || explicitGroup === 'view') return 'views';
if (explicitGroup === 'materializedViews' || explicitGroup === 'materialized-view') return 'materializedViews';
const explicitType = toTrimmedString(detail.objectType);
if (explicitType === 'view' || explicitType === 'views') return 'views';
if (explicitType === 'materialized' || explicitType === 'materialized-view') return 'materializedViews';
const tabId = toTrimmedString(detail.tabId);
const dbNodeKey = `${connectionId}-${dbName}`;
if (tabId.startsWith(`${dbNodeKey}-materialized-view-`)) return 'materializedViews';
if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views';
return 'tables';
@@ -103,7 +107,9 @@ export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTab
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName: objectName,
objectGroup: tab.type === 'view-def' ? 'views' : undefined,
objectGroup: tab.type === 'view-def'
? (tab.viewKind === 'materialized' ? 'materializedViews' : 'views')
: undefined,
});
};
@@ -113,9 +119,9 @@ export const resolveSidebarLocateTarget = (
): SidebarLocateTarget => {
const connectionKey = request.connectionId;
const databaseKey = `${request.connectionId}-${request.dbName}`;
const fallbackTargetKey = request.objectGroup === 'views'
? `${databaseKey}-view-${request.tableName}`
: `${databaseKey}-${request.tableName}`;
const fallbackTargetKey = request.objectGroup === 'materializedViews'
? `${databaseKey}-materialized-view-${request.tableName}`
: (request.objectGroup === 'views' ? `${databaseKey}-view-${request.tableName}` : `${databaseKey}-${request.tableName}`);
const targetKey = request.tabId || fallbackTargetKey;
const schemaSegment = request.schemaName || 'default';
const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined;
@@ -193,6 +199,11 @@ const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: Sideba
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
}
if (target.objectGroup === 'materializedViews') {
if (node.type !== 'materialized-view') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
}
if (node.type !== 'table') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName));
};

View File

@@ -37,7 +37,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
return `\`${raw.replace(/`/g, '``')}\``;
}
@@ -153,7 +153,7 @@ export const buildOrderBySQL = (
// 部分数据源在无显式排序需求时强制 ORDER BY即使按主键会显著放大大表预览成本
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误DuckDB 大文件可能被排序拖到连接超时。
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'duckdb') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'duckdb') {
return '';
}

View File

@@ -17,6 +17,7 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('OpenGauss')).toBe('opengauss');
expect(resolveSqlDialect('OceanBase')).toBe('oceanbase');
expect(resolveSqlDialect('doris')).toBe('diros');
expect(resolveSqlDialect('StarRocks')).toBe('starrocks');
expect(resolveSqlDialect('dameng')).toBe('dameng');
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
@@ -26,6 +27,7 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'oceanbase', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
expect(isMysqlFamilyDialect('oceanbase')).toBe(true);
expect(isMysqlFamilyDialect('starrocks')).toBe(true);
expect(isMysqlFamilyDialect('oracle')).toBe(false);
});
@@ -38,6 +40,7 @@ describe('sqlDialect', () => {
expect(values(resolveColumnTypeOptions('oceanbase'))).toContain('varchar(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
expect(values(resolveColumnTypeOptions('starrocks'))).toContain('PERCENTILE');
expect(values(resolveColumnTypeOptions('sphinx'))).toContain('text');
expect(values(resolveColumnTypeOptions('clickhouse'))).toContain('DateTime64(3)');
expect(values(resolveColumnTypeOptions('tdengine'))).toContain('TIMESTAMP');
@@ -55,6 +58,8 @@ describe('sqlDialect', () => {
it('resolves mysql-family completion keywords and functions with mysql syntax', () => {
expect(resolveSqlKeywords('mariadb')).toEqual(expect.arrayContaining(['LIMIT', 'CHANGE', 'AUTO_INCREMENT']));
expect(names(resolveSqlFunctions('diros'))).toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT']));
expect(resolveSqlKeywords('starrocks')).toEqual(expect.arrayContaining(['OLAP', 'DISTRIBUTED BY', 'BUCKETS', 'ADD ROLLUP', 'EXTERNAL CATALOG']));
expect(names(resolveSqlFunctions('starrocks'))).toEqual(expect.arrayContaining(['TO_BITMAP', 'HLL_UNION_AGG']));
});
it('resolves sqlserver completion without mysql-only ddl tokens', () => {

View File

@@ -12,6 +12,7 @@ export type SqlDialect =
| 'mariadb'
| 'oceanbase'
| 'diros'
| 'starrocks'
| 'sphinx'
| 'postgres'
| 'kingbase'
@@ -70,6 +71,8 @@ export const resolveSqlDialect = (
case 'doris':
case 'diros':
return 'diros';
case 'starrocks':
return 'starrocks';
case 'dm':
case 'dm8':
case 'dameng':
@@ -107,6 +110,7 @@ export const resolveSqlDialect = (
if (source.includes('mariadb')) return 'mariadb';
if (source.includes('mysql')) return 'mysql';
if (source.includes('doris') || source.includes('diros')) return 'diros';
if (source.includes('starrocks')) return 'starrocks';
if (source.includes('sphinx')) return 'sphinx';
if (source.includes('kingbase')) return 'kingbase';
if (source.includes('highgo')) return 'highgo';
@@ -123,7 +127,7 @@ export const resolveSqlDialect = (
};
export const isMysqlFamilyDialect = (dbType: string): boolean => (
['mysql', 'mariadb', 'oceanbase', 'diros', 'sphinx', 'tidb', 'starrocks'].includes(resolveSqlDialect(dbType))
['mysql', 'mariadb', 'oceanbase', 'diros', 'starrocks', 'sphinx', 'tidb'].includes(resolveSqlDialect(dbType))
);
export const isPgLikeDialect = (dbType: string): boolean => (
@@ -358,6 +362,30 @@ const DORIS_TYPES = optionValues([
'STRUCT<name:STRING>',
]);
const STARROCKS_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
'SMALLINT',
'INT',
'BIGINT',
'LARGEINT',
'FLOAT',
'DOUBLE',
'DECIMAL(10,2)',
'DATE',
'DATETIME',
'CHAR(50)',
'VARCHAR(255)',
'STRING',
'JSON',
'BITMAP',
'HLL',
'PERCENTILE',
'ARRAY<INT>',
'MAP<STRING,STRING>',
'STRUCT<name STRING>',
]);
const SPHINX_TYPES = optionValues([
'text',
'string',
@@ -444,6 +472,7 @@ const COMMON_TYPES = optionValues(['int', 'varchar(255)', 'text', 'datetime', 'd
export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'diros') return DORIS_TYPES;
if (dialect === 'starrocks') return STARROCKS_TYPES;
if (dialect === 'sphinx') return SPHINX_TYPES;
if (isMysqlFamilyDialect(dialect)) return MYSQL_TYPES;
if (isPgLikeDialect(dialect)) return PG_TYPES;
@@ -495,10 +524,19 @@ const CLICKHOUSE_KEYWORDS = [
'SAMPLE', 'MATERIALIZED', 'ALIAS', 'SETTINGS', 'TTL', 'CODEC',
];
const STARROCKS_KEYWORDS = [
'LIMIT', 'OFFSET', 'ENGINE', 'OLAP', 'DUPLICATE KEY', 'PRIMARY KEY',
'AGGREGATE KEY', 'UNIQUE KEY', 'DISTRIBUTED BY', 'HASH', 'BUCKETS',
'PARTITION BY', 'PROPERTIES', 'MATERIALIZED VIEW', 'REFRESH ASYNC',
'REFRESH MANUAL', 'ROLLUP', 'ADD ROLLUP', 'EXTERNAL CATALOG',
'CREATE EXTERNAL TABLE', 'BITMAP', 'HLL',
];
const TDENGINE_KEYWORDS = ['LIMIT', 'SLIMIT', 'SOFFSET', 'TAGS', 'USING', 'INTERVAL', 'FILL', 'PARTITION BY'];
export const resolveSqlKeywords = (dbType: string): string[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'starrocks') return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS, ...STARROCKS_KEYWORDS]);
if (isMysqlFamilyDialect(dialect)) return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS]);
if (isPgLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...PG_KEYWORDS]);
if (isOracleLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...ORACLE_KEYWORDS]);
@@ -692,6 +730,21 @@ const CLICKHOUSE_FUNCTIONS = [
fn('toInt64', 'ClickHouse - 转 Int64'),
];
const STARROCKS_FUNCTIONS = [
fn('DATE_FORMAT', 'StarRocks - 日期格式化'),
fn('STR_TO_DATE', 'StarRocks - 字符串转日期'),
fn('FROM_UNIXTIME', 'StarRocks - Unix 时间戳转时间'),
fn('TO_BITMAP', 'StarRocks - 构造 Bitmap'),
fn('BITMAP_UNION', 'StarRocks - Bitmap 聚合'),
fn('BITMAP_COUNT', 'StarRocks - Bitmap 计数'),
fn('HLL_HASH', 'StarRocks - HLL 哈希'),
fn('HLL_UNION_AGG', 'StarRocks - HLL 聚合'),
fn('APPROX_COUNT_DISTINCT', 'StarRocks - 近似去重'),
fn('PERCENTILE_APPROX', 'StarRocks - 近似分位数'),
fn('GET_JSON_STRING', 'StarRocks - JSON 字符串提取'),
fn('ARRAY_LENGTH', 'StarRocks - 数组长度'),
];
const TDENGINE_FUNCTIONS = [
fn('NOW', 'TDengine - 当前时间'),
fn('TODAY', 'TDengine - 当前日期'),
@@ -723,6 +776,7 @@ const mergeFunctions = (items: SqlFunctionCompletion[]): SqlFunctionCompletion[]
export const resolveSqlFunctions = (dbType: string): SqlFunctionCompletion[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'starrocks') return mergeFunctions([...COMMON_FUNCTIONS, ...MYSQL_FUNCTIONS, ...STARROCKS_FUNCTIONS]);
if (isMysqlFamilyDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...MYSQL_FUNCTIONS]);
if (isPgLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...PG_FUNCTIONS]);
if (isOracleLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...ORACLE_FUNCTIONS]);

View File

@@ -20,7 +20,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
if !isOceanBaseOracleProtocol(config) {
runConfig.Database = name
}
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
case "mysql", "mariadb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":

View File

@@ -77,6 +77,19 @@ func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) {
}
}
func TestNormalizeRunConfig_StarRocksUsesDatabaseFromTree(t *testing.T) {
t.Parallel()
runConfig := normalizeRunConfig(connection.ConnectionConfig{
Type: "starrocks",
Database: "default_cluster",
}, "analytics")
if runConfig.Database != "analytics" {
t.Fatalf("expected StarRocks database from tree, got %q", runConfig.Database)
}
}
func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *testing.T) {
t.Parallel()

View File

@@ -207,6 +207,8 @@ func defaultPortByType(driverType string) int {
return 2881
case "diros":
return 9030
case "starrocks":
return 9030
case "sphinx":
return 9306
case "postgres", "vastbase", "opengauss":

View File

@@ -131,6 +131,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "clickhouse" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "starrocks" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "mariadb" || dbType == "diros" || dbType == "oceanbase" {
// MariaDB uses same syntax as MySQL
} else if dbType == "sphinx" {
@@ -184,6 +186,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
return "sqlserver"
case "diros", "doris":
return "diros"
case "starrocks":
return "starrocks"
case "kingbase", "kingbase8", "kingbasees", "kingbasev8":
return "kingbase"
case "highgo":
@@ -213,6 +217,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
return "sqlserver"
case strings.Contains(driver, "diros"), strings.Contains(driver, "doris"):
return "diros"
case strings.Contains(driver, "starrocks"):
return "starrocks"
case strings.Contains(driver, "oceanbase"):
return "oceanbase"
default:
@@ -277,7 +283,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN
if strings.EqualFold(strings.TrimSpace(config.Type), "custom") {
// custom 连接的 dbName 语义依赖 driver尽量在常见驱动上对齐内置类型行为。
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "dameng", "sqlserver", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "dameng", "sqlserver", "clickhouse":
if strings.TrimSpace(dbName) != "" {
runConfig.Database = strings.TrimSpace(dbName)
}
@@ -312,8 +318,8 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "数据库重命名成功"}
case "mysql", "mariadb", "oceanbase", "sphinx":
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/OceanBase/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
case "mysql", "mariadb", "oceanbase", "starrocks", "sphinx":
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/OceanBase/StarRocks/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
case "postgres", "kingbase", "highgo", "vastbase", "opengauss":
if strings.EqualFold(strings.TrimSpace(config.Database), oldName) {
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
@@ -345,7 +351,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
sql string
)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "tdengine", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "tdengine", "clickhouse":
runConfig = config
runConfig.Database = ""
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
@@ -384,7 +390,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)}
}
@@ -398,7 +404,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
var sql string
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "clickhouse":
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
case "sqlserver":
@@ -430,7 +436,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "tdengine", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "tdengine", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
}
@@ -1047,7 +1053,7 @@ func supportsCreateStatementFallback(dbType string) bool {
func supportsViewCreateStatementLookup(dbType string) bool {
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "oracle", "dameng", "sqlite", "duckdb", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "oracle", "dameng", "sqlite", "duckdb", "clickhouse":
return true
default:
return false
@@ -1246,7 +1252,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
}
@@ -1281,7 +1287,7 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "duckdb":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "opengauss", "sqlserver", "duckdb":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除函数/存储过程", dbType)}
}
@@ -1335,7 +1341,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN
var sql string
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "clickhouse":
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
case "postgres", "kingbase", "highgo", "vastbase", "opengauss":

View File

@@ -336,6 +336,7 @@ const builtinDriverManifestJSON = `{
"mariadb": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mariadb" },
"oceanbase": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/oceanbase" },
"doris": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/doris" },
"starrocks": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/starrocks" },
"sphinx": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" },
"sqlserver": { "engine": "go", "version": "1.9.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" },
"sqlite": { "engine": "go", "version": "1.44.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" },
@@ -391,6 +392,7 @@ var latestDriverVersionMap = map[string]string{
"mariadb": "1.9.3",
"oceanbase": "1.9.3",
"diros": "1.9.3",
"starrocks": "1.9.3",
"sphinx": "1.9.3",
"sqlserver": "1.9.6",
"sqlite": "1.46.1",
@@ -412,6 +414,7 @@ var driverGoModulePathMap = map[string]string{
"mariadb": "github.com/go-sql-driver/mysql",
"oceanbase": "github.com/go-sql-driver/mysql",
"diros": "github.com/go-sql-driver/mysql",
"starrocks": "github.com/go-sql-driver/mysql",
"sphinx": "github.com/go-sql-driver/mysql",
"sqlserver": "github.com/microsoft/go-mssqldb",
"sqlite": "modernc.org/sqlite",
@@ -1472,6 +1475,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
buildOptionalGoDriverDefinition("mariadb", "MariaDB", packages),
buildOptionalGoDriverDefinition("oceanbase", "OceanBase", packages),
buildOptionalGoDriverDefinition("diros", "Doris", packages),
buildOptionalGoDriverDefinition("starrocks", "StarRocks", packages),
buildOptionalGoDriverDefinition("sphinx", "Sphinx", packages),
buildOptionalGoDriverDefinition("sqlserver", "SQL Server", packages),
buildOptionalGoDriverDefinition("sqlite", "SQLite", packages),
@@ -3780,6 +3784,8 @@ func optionalDriverBuildTag(driverType string, selectedVersion string) (string,
return "gonavi_oceanbase_driver", nil
case "diros":
return "gonavi_diros_driver", nil
case "starrocks":
return "gonavi_starrocks_driver", nil
case "sphinx":
return "gonavi_sphinx_driver", nil
case "sqlserver":

View File

@@ -128,6 +128,7 @@ func optionalDriverAgentRevisionTestDrivers(t *testing.T) []string {
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"sqlserver",
"sqlite",

View File

@@ -1251,7 +1251,7 @@ const (
func supportsTruncateTableForDBType(dbType string) bool {
switch strings.ToLower(strings.TrimSpace(dbType)) {
case "mysql", "mariadb", "oceanbase", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "oracle", "dameng", "clickhouse", "duckdb":
case "mysql", "mariadb", "oceanbase", "starrocks", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "oracle", "dameng", "clickhouse", "duckdb":
return true
default:
return false
@@ -1386,7 +1386,7 @@ func quoteIdentByType(dbType string, ident string) string {
dbType = resolveDDLDBType(connection.ConnectionConfig{Type: dbType})
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "tdengine", "clickhouse":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "tdengine", "clickhouse":
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
case "kingbase":
return db.QuoteKingbaseIdentifier(ident)
@@ -1608,7 +1608,7 @@ func buildListViewQueries(config connection.ConnectionConfig, dbName string) []s
dbType := resolveDDLDBType(config)
escapedDbName := escapeSQLLiteral(dbName)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx":
queries := []string{
fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName),
}
@@ -1711,7 +1711,7 @@ func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaNa
escapedDB := escapeSQLLiteral(dbName)
switch dbType {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx":
if safeSchema == "" {
safeSchema = strings.TrimSpace(dbName)
}
@@ -1991,7 +1991,7 @@ func formatSQLValue(dbType string, v interface{}) string {
return "'" + val.Format("2006-01-02 15:04:05") + "'"
case string:
normalizedType := strings.ToLower(strings.TrimSpace(dbType))
if (normalizedType == "mysql" || normalizedType == "oceanbase" || normalizedType == "diros") && isMySQLHexLiteral(val) {
if (normalizedType == "mysql" || normalizedType == "oceanbase" || normalizedType == "diros" || normalizedType == "starrocks") && isMySQLHexLiteral(val) {
return val
}
escaped := strings.ReplaceAll(val, "'", "''")

View File

@@ -6,6 +6,7 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("oceanbase"), "oceanbase")
registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris")
registerDatabaseFactory(newOptionalDriverAgentDatabase("starrocks"), "starrocks")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite")

View File

@@ -6,6 +6,7 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("oceanbase"), "oceanbase")
registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris")
registerDatabaseFactory(newOptionalDriverAgentDatabase("starrocks"), "starrocks")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver")
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite")

View File

@@ -7,6 +7,7 @@ func init() {
"mariadb": "src-4e1ec648c70c87ea",
"oceanbase": "src-8e445fc4899d850f",
"diros": "src-74927b3809258666",
"starrocks": "src-3b5aad8a32f79b61",
"sphinx": "src-269bd60a34df47d3",
"sqlserver": "src-84553484c72e7253",
"sqlite": "src-762863d48f653b89",

View File

@@ -24,6 +24,7 @@ var optionalGoDrivers = map[string]struct{}{
"mariadb": {},
"oceanbase": {},
"diros": {},
"starrocks": {},
"sphinx": {},
"sqlserver": {},
"sqlite": {},
@@ -78,6 +79,8 @@ func driverDisplayName(driverType string) string {
return "OceanBase"
case "diros":
return "Doris"
case "starrocks":
return "StarRocks"
case "sphinx":
return "Sphinx"
case "postgres":

View File

@@ -113,7 +113,7 @@ func TestNewCompatibleDriversAreOptionalAgentDrivers(t *testing.T) {
tmpDir := t.TempDir()
SetExternalDriverDownloadDirectory(tmpDir)
for _, driverType := range []string{"oceanbase", "opengauss", "open_gauss"} {
for _, driverType := range []string{"oceanbase", "opengauss", "open_gauss", "starrocks"} {
if IsBuiltinDriver(driverType) {
t.Fatalf("%s 不应是免安装内置驱动", driverType)
}

View File

@@ -0,0 +1,210 @@
//go:build gonavi_full_drivers || gonavi_starrocks_driver
package db
import (
"database/sql"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
mysqlDriver "github.com/go-sql-driver/mysql"
)
const (
starRocksDriverName = "starrocks"
defaultStarRocksPort = 9030
)
// StarRocksDB 使用独立 driver 名称接入,底层协议兼容 MySQL。
type StarRocksDB struct {
MySQLDB
}
func init() {
for _, name := range sql.Drivers() {
if name == starRocksDriverName {
return
}
}
sql.Register(starRocksDriverName, &mysqlDriver.MySQLDriver{})
}
func applyStarRocksURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
parsed, ok := parseMySQLCompatibleURI(uriText, "starrocks", "mysql")
if !ok {
return config
}
if parsed.User != nil {
if config.User == "" {
config.User = parsed.User.Username()
}
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
config.Password = pass
}
}
if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" && config.Database == "" {
config.Database = dbName
}
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultStarRocksPort
}
hostsFromURI := make([]string, 0, 4)
hostText := strings.TrimSpace(parsed.Host)
if hostText != "" {
for _, entry := range strings.Split(hostText, ",") {
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
if !ok {
continue
}
hostsFromURI = append(hostsFromURI, normalizeMySQLAddress(host, port))
}
}
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
config.Hosts = hostsFromURI
}
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
if ok {
config.Host = host
config.Port = port
}
}
if config.Topology == "" {
topology := strings.TrimSpace(parsed.Query().Get("topology"))
if topology != "" {
config.Topology = strings.ToLower(topology)
}
}
return config
}
func collectStarRocksAddresses(config connection.ConnectionConfig) []string {
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultStarRocksPort
}
candidates := make([]string, 0, len(config.Hosts)+1)
if len(config.Hosts) > 0 {
candidates = append(candidates, config.Hosts...)
} else {
candidates = append(candidates, normalizeMySQLAddress(config.Host, defaultPort))
}
result := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, entry := range candidates {
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
if !ok {
continue
}
normalized := normalizeMySQLAddress(host, port)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
func (s *StarRocksDB) getDSN(config connection.ConnectionConfig) (string, error) {
database := config.Database
protocol := "tcp"
address := normalizeMySQLAddress(config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err != nil {
return "", fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
protocol = netName
}
return buildMySQLCompatibleDSN(config, protocol, address, database), nil
}
func resolveStarRocksCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
primaryUser := strings.TrimSpace(config.User)
primaryPassword := config.Password
replicaUser := strings.TrimSpace(config.MySQLReplicaUser)
replicaPassword := config.MySQLReplicaPassword
if addressIndex > 0 && replicaUser != "" {
return replicaUser, replicaPassword
}
if primaryUser == "" && replicaUser != "" {
return replicaUser, replicaPassword
}
return config.User, primaryPassword
}
func (s *StarRocksDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyStarRocksURI(config)
addresses := collectStarRocksAddresses(runConfig)
if len(addresses) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 StarRocks 地址")
}
var errorDetails []string
for index, address := range addresses {
candidateConfig := runConfig
host, port, ok := parseHostPortWithDefault(address, defaultStarRocksPort)
if !ok {
continue
}
candidateConfig.Host = host
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveStarRocksCredential(runConfig, index)
dsn, err := s.getDSN(candidateConfig)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 生成连接串失败: %v", address, err))
continue
}
db, err := sql.Open(starRocksDriverName, dsn)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))
continue
}
timeout := getConnectTimeout(candidateConfig)
ctx, cancel := utils.ContextWithTimeout(timeout)
pingErr := db.PingContext(ctx)
cancel()
if pingErr != nil {
_ = db.Close()
errorDetails = append(errorDetails, fmt.Sprintf("%s 验证失败: %v", address, pingErr))
continue
}
s.conn = db
s.pingTimeout = timeout
return nil
}
if len(errorDetails) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 StarRocks 地址")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ""))
}

View File

@@ -111,7 +111,7 @@ func classifyMigrationDataModel(dbType string) MigrationDataModel {
return MigrationDataModelRelational
case "mongodb":
return MigrationDataModelDocument
case "clickhouse", "diros", "sphinx":
case "clickhouse", "diros", "starrocks", "sphinx":
return MigrationDataModelColumnar
case "tdengine":
return MigrationDataModelTimeSeries

View File

@@ -15,6 +15,7 @@ func TestClassifyMigrationDataModel(t *testing.T) {
"kingbase": MigrationDataModelRelational,
"mongodb": MigrationDataModelDocument,
"clickhouse": MigrationDataModelColumnar,
"starrocks": MigrationDataModelColumnar,
"tdengine": MigrationDataModelTimeSeries,
"redis": MigrationDataModelKeyValue,
"custom": MigrationDataModelCustom,

View File

@@ -45,6 +45,8 @@ func resolveMigrationDBType(config connection.ConnectionConfig) string {
return "sphinx"
case "diros", "doris":
return "diros"
case "starrocks":
return "starrocks"
case "kingbase", "kingbase8", "kingbasees", "kingbasev8":
return "kingbase"
case "highgo":
@@ -76,6 +78,8 @@ func resolveMigrationDBType(config connection.ConnectionConfig) string {
return "sphinx"
case strings.Contains(driver, "diros"), strings.Contains(driver, "doris"):
return "diros"
case strings.Contains(driver, "starrocks"):
return "starrocks"
case strings.Contains(driver, "maria"):
return "mariadb"
case strings.Contains(driver, "oceanbase"):
@@ -91,7 +95,7 @@ func resolveMigrationDBType(config connection.ConnectionConfig) string {
func isMySQLCoreType(dbType string) bool {
switch normalizeMigrationDBType(dbType) {
case "mysql", "mariadb", "oceanbase", "diros":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks":
return true
default:
return false

View File

@@ -26,7 +26,7 @@ func quoteIdentByType(dbType string, ident string) string {
}
switch normalizeMigrationDBType(dbType) {
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "clickhouse", "tdengine":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "clickhouse", "tdengine":
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
case "kingbase":
return db.QuoteKingbaseIdentifier(ident)
@@ -140,7 +140,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
return raw
}
return s + "." + table
case "mysql", "mariadb", "oceanbase", "diros", "sphinx", "clickhouse", "tdengine":
case "mysql", "mariadb", "oceanbase", "diros", "starrocks", "sphinx", "clickhouse", "tdengine":
s := strings.TrimSpace(schema)
if s == "" || table == "" {
return table

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_DRIVERS=(mariadb oceanbase diros starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTPUT_FILE="internal/db/driver_agent_revisions_gen.go"
usage() {
@@ -27,7 +27,7 @@ normalize_driver() {
doris|diros) echo "diros" ;;
oceanbase) echo "oceanbase" ;;
opengauss|open_gauss|open-gauss) echo "opengauss" ;;
mariadb|diros|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
mariadb|diros|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
echo "$value"
;;
*)
@@ -97,6 +97,8 @@ oceanbase:internal/db/oracle_impl.go|\
oceanbase:internal/db/mysql_impl.go|\
diros:internal/db/diros_impl.go|\
diros:internal/db/mysql_impl.go|\
starrocks:internal/db/starrocks_impl.go|\
starrocks:internal/db/mysql_impl.go|\
sphinx:internal/db/sphinx_impl.go|\
sphinx:internal/db/mysql_impl.go|\
sqlserver:internal/db/sqlserver_impl.go|\