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

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